Refactor `IHtmlGenerator.GenerateSelect()` and address #2240

- add `IHtmlGenerator.GetCurrentValues()` method
 - bring together bits of `GenerateSelect()` and `UpdateSelectListItemsWithDefaultValue()`
 - get rid of ugly `out` parameter
 - also allows `<option/>` tag helpers to run before `<select/>` helper generation
- match `null` values and `SelectListItem`s with empty values
- match `enum` names correctly
- add doc comments for `IHtmlGenerator.GenerateSelect()` methods
This commit is contained in:
Doug Bunting 2015-03-23 10:51:13 -07:00
parent 0e783ace58
commit 9ac6ebd2b2
6 changed files with 971 additions and 101 deletions

View File

@ -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 = @"<li style=""display:none""></li>";
private static readonly MethodInfo ConvertEnumFromStringMethod =
typeof(DefaultHtmlGenerator).GetTypeInfo().GetDeclaredMethod(nameof(ConvertEnumFromString));
private readonly AntiForgery _antiForgery;
private readonly IScopedInstance<ActionBindingContext> _bindingContextAccessor;
@ -377,16 +380,16 @@ namespace Microsoft.AspNet.Mvc.Rendering
bool allowMultiple,
object htmlAttributes)
{
ICollection<string> ignored;
var currentValues = GetCurrentValues(viewContext, modelExplorer, expression, allowMultiple);
return GenerateSelect(
viewContext,
modelExplorer,
optionLabel,
expression,
selectList,
currentValues,
allowMultiple,
htmlAttributes,
selectedValues: out ignored);
htmlAttributes);
}
/// <inheritdoc />
@ -396,9 +399,9 @@ namespace Microsoft.AspNet.Mvc.Rendering
string optionLabel,
string expression,
IEnumerable<SelectListItem> selectList,
IReadOnlyCollection<string> currentValues,
bool allowMultiple,
object htmlAttributes,
out ICollection<string> 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
{
// <select/>, Html.DropDownListFor() and Html.ListBoxFor() helper case. Do not use ViewData.
defaultValue = modelExplorer.Model;
}
}
if (defaultValue != null)
{
selectList =
UpdateSelectListItemsWithDefaultValue(selectList, defaultValue, allowMultiple, out selectedValues);
}
else
{
selectedValues = new string[0];
selectList = UpdateSelectListItemsWithDefaultValue(modelExplorer, selectList, currentValues);
}
// Convert each ListItem to an <option> tag and wrap them with <optgroup> if requested.
@ -746,18 +722,137 @@ namespace Microsoft.AspNet.Mvc.Rendering
modelExplorer.Metadata,
_metadataProvider,
viewContext.HttpContext.RequestServices);
var validatorProviderContext = new ModelValidatorProviderContext(modelExplorer.Metadata);
validatorProvider.GetValidators(validatorProviderContext);
var validators = validatorProviderContext.Validators;
return
return
validators
.OfType<IClientModelValidator>()
.SelectMany(v => v.GetClientValidationRules(validationContext));
}
/// <inheritdoc />
public virtual IReadOnlyCollection<string> GetCurrentValues(
[NotNull] ViewContext viewContext,
ModelExplorer modelExplorer,
string expression,
bool allowMultiple)
{
var fullName = GetFullHtmlFieldName(viewContext, expression);
if (string.IsNullOrEmpty(fullName))
{
throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(expression));
}
var type = allowMultiple ? typeof(string[]) : typeof(string);
var rawValue = GetModelStateValue(viewContext, fullName, type);
// If ModelState did not contain a current value, fall back to ViewData- or ModelExplorer-supplied value.
if (rawValue == null)
{
if (modelExplorer == null)
{
// Html.DropDownList() and Html.ListBox() helper case.
rawValue = viewContext.ViewData.Eval(expression);
if (rawValue is IEnumerable<SelectListItem>)
{
// This ViewData item contains the fallback selectList collection for GenerateSelect().
// Do not try to use this collection.
rawValue = null;
}
}
else
{
// <select/>, 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<string>(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<string>)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<TEnum>(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<SelectListItem> UpdateSelectListItemsWithDefaultValue(
ModelExplorer modelExplorer,
IEnumerable<SelectListItem> selectList,
object defaultValue,
bool allowMultiple,
out ICollection<string> selectedValues)
IReadOnlyCollection<string> 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<object>().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<Enum>().Select(value => value.ToString());
values = values.Concat(enumValues);
selectedValues = new HashSet<string>(values, StringComparer.OrdinalIgnoreCase);
// Perform deep copy of selectList to avoid changing user's Selected property values.
var newSelectList = new List<SelectListItem>();
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;

View File

@ -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)

View File

@ -159,6 +159,40 @@ namespace Microsoft.AspNet.Mvc.Rendering
object routeValues,
object htmlAttributes);
/// <summary>
/// Generate a &lt;select&gt; element for the <paramref name="expression"/>.
/// </summary>
/// <param name="viewContext">A <see cref="ViewContext"/> instance for the current scope.</param>
/// <param name="modelExplorer">
/// <see cref="ModelExplorer"/> for the <paramref name="expression"/>. If <c>null</c>, determines validation
/// attributes using <paramref name="viewContext"/> and the <paramref name="expression"/>.
/// </param>
/// <param name="optionLabel">Optional text for a default empty &lt;option&gt; element.</param>
/// <param name="expression">Expression name, relative to the current model.</param>
/// <param name="selectList">
/// A collection of <see cref="SelectListItem"/> objects used to populate the &lt;select&gt; element with
/// &lt;optgroup&gt; and &lt;option&gt; elements. If <c>null</c>, finds this collection at
/// <c>ViewContext.ViewData[expression]</c>.
/// </param>
/// <param name="allowMultiple">
/// If <c>true</c>, includes a <c>multiple</c> attribute in the generated HTML. Otherwise generates a
/// single-selection &lt;select&gt; element.
/// </param>
/// <param name="htmlAttributes">
/// An <see cref="object"/> that contains the HTML attributes for the &lt;select&gt; element. Alternatively, an
/// <see cref="IDictionary{string, object}"/> instance containing the HTML attributes.
/// </param>
/// <returns>A new <see cref="TagBuilder"/> describing the &lt;select&gt; element.</returns>
/// <remarks>
/// <para>
/// Combines <see cref="TemplateInfo.HtmlFieldPrefix"/> and <paramref name="expression"/> to set
/// &lt;select&gt; element's "name" attribute. Sanitizes <paramref name="expression"/> to set element's "id"
/// attribute.
/// </para>
/// <para>
/// See <see cref="GetCurrentValues"/> for information about how current values are determined.
/// </para>
/// </remarks>
TagBuilder GenerateSelect(
[NotNull] ViewContext viewContext,
ModelExplorer modelExplorer,
@ -168,15 +202,55 @@ namespace Microsoft.AspNet.Mvc.Rendering
bool allowMultiple,
object htmlAttributes);
/// <summary>
/// Generate a &lt;select&gt; element for the <paramref name="expression"/>.
/// </summary>
/// <param name="viewContext">A <see cref="ViewContext"/> instance for the current scope.</param>
/// <param name="modelExplorer">
/// <see cref="ModelExplorer"/> for the <paramref name="expression"/>. If <c>null</c>, determines validation
/// attributes using <paramref name="viewContext"/> and the <paramref name="expression"/>.
/// </param>
/// <param name="optionLabel">Optional text for a default empty &lt;option&gt; element.</param>
/// <param name="expression">Expression name, relative to the current model.</param>
/// <param name="selectList">
/// A collection of <see cref="SelectListItem"/> objects used to populate the &lt;select&gt; element with
/// &lt;optgroup&gt; and &lt;option&gt; elements. If <c>null</c>, finds this collection at
/// <c>ViewContext.ViewData[expression]</c>.
/// </param>
/// <param name="currentValues">
/// An <see cref="IReadOnlyCollection{string}"/> containing values for &lt;option&gt; elements to select. If
/// <c>null</c>, selects &lt;option&gt; elements based on <see cref="SelectListItem.Selected"/> values in
/// <paramref name="selectList"/>.
/// </param>
/// <param name="allowMultiple">
/// If <c>true</c>, includes a <c>multiple</c> attribute in the generated HTML. Otherwise generates a
/// single-selection &lt;select&gt; element.
/// </param>
/// <param name="htmlAttributes">
/// An <see cref="object"/> that contains the HTML attributes for the &lt;select&gt; element. Alternatively, an
/// <see cref="IDictionary{string, object}"/> instance containing the HTML attributes.
/// </param>
/// <returns>A new <see cref="TagBuilder"/> describing the &lt;select&gt; element.</returns>
/// <remarks>
/// <para>
/// Combines <see cref="TemplateInfo.HtmlFieldPrefix"/> and <paramref name="expression"/> to set
/// &lt;select&gt; element's "name" attribute. Sanitizes <paramref name="expression"/> to set element's "id"
/// attribute.
/// </para>
/// <para>
/// See <see cref="GetCurrentValues"/> for information about how the <paramref name="currentValues"/>
/// collection may be created.
/// </para>
/// </remarks>
TagBuilder GenerateSelect(
[NotNull] ViewContext viewContext,
ModelExplorer modelExplorer,
string optionLabel,
string expression,
IEnumerable<SelectListItem> selectList,
IReadOnlyCollection<string> currentValues,
bool allowMultiple,
object htmlAttributes,
out ICollection<string> selectedValues);
object htmlAttributes);
TagBuilder GenerateTextArea(
[NotNull] ViewContext viewContext,
@ -216,5 +290,45 @@ namespace Microsoft.AspNet.Mvc.Rendering
[NotNull] ViewContext viewContext,
ModelExplorer modelExplorer,
string expression);
/// <summary>
/// Gets the collection of current values for the given <paramref name="expression"/>.
/// </summary>
/// <param name="viewContext">A <see cref="ViewContext"/> instance for the current scope.</param>
/// <param name="modelExplorer">
/// <see cref="ModelExplorer"/> for the <paramref name="expression"/>. If <c>null</c>, calculates the
/// <paramref name="expression"/> result using <see cref="ViewDataDictionary.Eval(string)"/>.
/// </param>
/// <param name="expression">Expression name, relative to the current model.</param>
/// <param name="allowMultiple">
/// If <c>true</c>, require a collection <paramref name="expression"/> result. Otherwise, treat result as a
/// single value.
/// </param>
/// <returns>
/// <para>
/// <c>null</c> if no <paramref name="expression"/> result is found. Otherwise an
/// <see cref="IReadOnlyCollection{string}"/> containing current values for the given
/// <paramref name="expression"/>.
/// </para>
/// <para>
/// Converts the <paramref name="expression"/> result to a <see cref="string"/>. If that result is an
/// <see cref="System.Collections.IEnumerable"/> type, instead converts each item in the collection and returns
/// them separately.
/// </para>
/// <para>
/// If the <paramref name="expression"/> result or the element type is an <see cref="System.Enum"/>, returns a
/// <see cref="string"/> containing the integer representation of the <see cref="System.Enum"/> value as well
/// as all <see cref="System.Enum"/> names for that value. Otherwise returns the default <see cref="string"/>
/// conversion of the value.
/// </para>
/// </returns>
/// <remarks>
/// See <see cref="GenerateSelect"/> for information about how the return value may be used.
/// </remarks>
IReadOnlyCollection<string> GetCurrentValues(
[NotNull] ViewContext viewContext,
ModelExplorer modelExplorer,
string expression,
bool allowMultiple);
}
}

View File

@ -79,16 +79,20 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
// Ensure GenerateSelect() _never_ looks anything up in ViewData.
var items = Items ?? Enumerable.Empty<SelectListItem>();
ICollection<string> 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 <option/>
// elements. Provide selected values for <option/> tag helpers. They'll run next.
ViewContext.FormContext.FormData[SelectedValuesFormDataKey] = selectedValues;
ViewContext.FormContext.FormData[SelectedValuesFormDataKey] = currentValues;
}
}
}

View File

@ -0,0 +1,646 @@
// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using Microsoft.AspNet.DataProtection;
using Microsoft.AspNet.Mvc.ModelBinding;
using Microsoft.Framework.OptionsModel;
using Microsoft.Framework.WebEncoders;
using Moq;
using Xunit;
namespace Microsoft.AspNet.Mvc.Rendering
{
public class DefaultHtmlGeneratorTest
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public void GetCurrentValues_WithEmptyViewData_ReturnsNull(bool allowMultiple)
{
// Arrange
var metadataProvider = new TestModelMetadataProvider();
var htmlGenerator = GetGenerator(metadataProvider);
var viewContext = GetViewContext<Model>(model: null, metadataProvider: metadataProvider);
// Act
var result = htmlGenerator.GetCurrentValues(
viewContext,
modelExplorer: null,
expression: nameof(Model.Name),
allowMultiple: allowMultiple);
// Assert
Assert.Null(result);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void GetCurrentValues_WithNullExpressionResult_ReturnsNull(bool allowMultiple)
{
// Arrange
var metadataProvider = new TestModelMetadataProvider();
var htmlGenerator = GetGenerator(metadataProvider);
var viewContext = GetViewContext<Model>(model: null, metadataProvider: metadataProvider);
var modelExplorer = metadataProvider.GetModelExplorerForType(typeof(string), model: null);
// Act
var result = htmlGenerator.GetCurrentValues(
viewContext,
modelExplorer,
expression: nameof(Model.Name),
allowMultiple: allowMultiple);
// Assert
Assert.Null(result);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void GetCurrentValues_WithSelectListInViewData_ReturnsNull(bool allowMultiple)
{
// Arrange
var metadataProvider = new TestModelMetadataProvider();
var htmlGenerator = GetGenerator(metadataProvider);
var viewContext = GetViewContext<Model>(model: null, metadataProvider: metadataProvider);
viewContext.ViewData[nameof(Model.Name)] = Enumerable.Empty<SelectListItem>();
// Act
var result = htmlGenerator.GetCurrentValues(
viewContext,
modelExplorer: null,
expression: nameof(Model.Name),
allowMultiple: allowMultiple);
// Assert
Assert.Null(result);
}
[Theory]
[InlineData("some string")] // treated as if it were not IEnumerable
[InlineData(23)]
[InlineData(RegularEnum.Three)]
public void GetCurrentValues_AllowMultipleWithNonEnumerableInViewData_Throws(object value)
{
// Arrange
var metadataProvider = new TestModelMetadataProvider();
var htmlGenerator = GetGenerator(metadataProvider);
var viewContext = GetViewContext<Model>(model: null, metadataProvider: metadataProvider);
viewContext.ViewData[nameof(Model.Name)] = value;
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => htmlGenerator.GetCurrentValues(
viewContext,
modelExplorer: null,
expression: nameof(Model.Name),
allowMultiple: true));
Assert.Equal(
"The parameter 'expression' must evaluate to an IEnumerable when multiple selection is allowed.",
exception.Message);
}
// rawValue, allowMultiple -> expected current values
public static TheoryData<object, bool, IReadOnlyCollection<string>> GetCurrentValues_StringAndCollectionData
{
get
{
return new TheoryData<object, bool, IReadOnlyCollection<string>>
{
// ModelStateDictionary converts single values to arrays and visa-versa.
{ string.Empty, false, new [] { string.Empty } },
{ string.Empty, true, new [] { string.Empty } },
{ "some string", false, new [] { "some string" } },
{ "some string", true, new [] { "some string" } },
{ new [] { "some string" }, false, new [] { "some string" } },
{ new [] { "some string" }, true, new [] { "some string" } },
{ new [] { "some string", "some other string" }, false, new [] { "some string" } },
{
new [] { "some string", "some other string" },
true,
new [] { "some string", "some other string" }
},
// { new string[] { null }, false, null } would fall back to other sources.
{ new string[] { null }, true, new [] { string.Empty } },
{ new [] { string.Empty }, false, new [] { string.Empty } },
{ new [] { string.Empty }, true, new [] { string.Empty } },
{
new [] { null, "some string", "some other string" },
true,
new [] { string.Empty, "some string", "some other string" }
},
// ignores duplicates
{
new [] { null, "some string", null, "some other string", null, "some string", null },
true,
new [] { string.Empty, "some string", "some other string" }
},
// ignores case of duplicates
{
new [] { "some string", "SoMe StriNg", "Some String", "soME STRing", "SOME STRING" },
true,
new [] { "some string" }
},
};
}
}
[Theory]
[MemberData(nameof(GetCurrentValues_StringAndCollectionData))]
public void GetCurrentValues_WithModelStateEntryAndViewData_ReturnsModelStateEntry(
object rawValue,
bool allowMultiple,
IReadOnlyCollection<string> expected)
{
// Arrange
var metadataProvider = new TestModelMetadataProvider();
var htmlGenerator = GetGenerator(metadataProvider);
var model = new Model { Name = "ignored property value" };
var viewContext = GetViewContext<Model>(model, metadataProvider);
viewContext.ViewData[nameof(Model.Name)] = "ignored ViewData value";
var valueProviderResult = new ValueProviderResult(
rawValue,
attemptedValue: null,
culture: CultureInfo.InvariantCulture);
viewContext.ModelState.SetModelValue(nameof(Model.Name), valueProviderResult);
// Act
var result = htmlGenerator.GetCurrentValues(
viewContext,
modelExplorer: null,
expression: nameof(Model.Name),
allowMultiple: allowMultiple);
// Assert
Assert.NotNull(result);
Assert.Equal<string>(expected, result);
}
[Theory]
[MemberData(nameof(GetCurrentValues_StringAndCollectionData))]
public void GetCurrentValues_WithModelStateEntryModelExplorerAndViewData_ReturnsModelStateEntry(
object rawValue,
bool allowMultiple,
IReadOnlyCollection<string> expected)
{
// Arrange
var metadataProvider = new TestModelMetadataProvider();
var htmlGenerator = GetGenerator(metadataProvider);
var model = new Model { Name = "ignored property value" };
var viewContext = GetViewContext<Model>(model, metadataProvider);
viewContext.ViewData[nameof(Model.Name)] = "ignored ViewData value";
var modelExplorer = metadataProvider.GetModelExplorerForType(typeof(string), "ignored model value");
var valueProviderResult = new ValueProviderResult(
rawValue,
attemptedValue: null,
culture: CultureInfo.InvariantCulture);
viewContext.ModelState.SetModelValue(nameof(Model.Name), valueProviderResult);
// Act
var result = htmlGenerator.GetCurrentValues(
viewContext,
modelExplorer,
expression: nameof(Model.Name),
allowMultiple: allowMultiple);
// Assert
Assert.NotNull(result);
Assert.Equal<string>(expected, result);
}
// rawValue -> expected current values
public static TheoryData<string, string[]> GetCurrentValues_StringData
{
get
{
return new TheoryData<string, string[]>
{
// 1. If given a ModelExplorer, GetCurrentValues does not use ViewData even if expression result is
// null.
// 2. Otherwise if ViewData entry exists, GetCurrentValue does not fall back to ViewData.Model even
// if entry is null.
// 3. Otherwise, GetCurrentValue does not fall back anywhere else even if ViewData.Model is null.
{ null, null },
{ string.Empty, new [] { string.Empty } },
{ "some string", new [] { "some string" } },
};
}
}
[Theory]
[MemberData(nameof(GetCurrentValues_StringData))]
public void GetCurrentValues_WithModelExplorerAndViewData_ReturnsExpressionResult(
string rawValue,
IReadOnlyCollection<string> expected)
{
// Arrange
var metadataProvider = new TestModelMetadataProvider();
var htmlGenerator = GetGenerator(metadataProvider);
var model = new Model { Name = "ignored property value" };
var viewContext = GetViewContext<Model>(model, metadataProvider);
viewContext.ViewData[nameof(Model.Name)] = "ignored ViewData value";
var modelExplorer = metadataProvider.GetModelExplorerForType(typeof(string), rawValue);
var valueProviderResult = new ValueProviderResult(
rawValue: null,
attemptedValue: null,
culture: CultureInfo.InvariantCulture);
viewContext.ModelState.SetModelValue(nameof(Model.Name), valueProviderResult);
// Act
var result = htmlGenerator.GetCurrentValues(
viewContext,
modelExplorer,
expression: nameof(Model.Name),
allowMultiple: false);
// Assert
Assert.Equal<string>(expected, result);
}
[Theory]
[MemberData(nameof(GetCurrentValues_StringData))]
public void GetCurrentValues_WithViewData_ReturnsViewDataEntry(
object rawValue,
IReadOnlyCollection<string> expected)
{
// Arrange
var metadataProvider = new TestModelMetadataProvider();
var htmlGenerator = GetGenerator(metadataProvider);
var model = new Model { Name = "ignored property value" };
var viewContext = GetViewContext<Model>(model, metadataProvider);
viewContext.ViewData[nameof(Model.Name)] = rawValue;
var valueProviderResult = new ValueProviderResult(
rawValue: null,
attemptedValue: null,
culture: CultureInfo.InvariantCulture);
viewContext.ModelState.SetModelValue(nameof(Model.Name), valueProviderResult);
// Act
var result = htmlGenerator.GetCurrentValues(
viewContext,
modelExplorer: null,
expression: nameof(Model.Name),
allowMultiple: false);
// Assert
Assert.Equal<string>(expected, result);
}
[Theory]
[MemberData(nameof(GetCurrentValues_StringData))]
public void GetCurrentValues_WithModel_ReturnsModel(string rawValue, IReadOnlyCollection<string> expected)
{
// Arrange
var metadataProvider = new TestModelMetadataProvider();
var htmlGenerator = GetGenerator(metadataProvider);
var model = new Model { Name = rawValue };
var viewContext = GetViewContext<Model>(model, metadataProvider);
var valueProviderResult = new ValueProviderResult(
rawValue: null,
attemptedValue: null,
culture: CultureInfo.InvariantCulture);
viewContext.ModelState.SetModelValue(nameof(Model.Name), valueProviderResult);
// Act
var result = htmlGenerator.GetCurrentValues(
viewContext,
modelExplorer: null,
expression: nameof(Model.Name),
allowMultiple: false);
// Assert
Assert.Equal<string>(expected, result);
}
// rawValue -> expected current values
public static TheoryData<string[], string[]> GetCurrentValues_StringCollectionData
{
get
{
return new TheoryData<string[], string[]>
{
{ new string[] { null }, new [] { string.Empty } },
{ new [] { string.Empty }, new [] { string.Empty } },
{ new [] { "some string" }, new [] { "some string" } },
{ new [] { "some string", "some other string" }, new [] { "some string", "some other string" } },
{
new [] { null, "some string", "some other string" },
new [] { string.Empty, "some string", "some other string" }
},
// ignores duplicates
{
new [] { null, "some string", null, "some other string", null, "some string", null },
new [] { string.Empty, "some string", "some other string" }
},
// ignores case of duplicates
{
new [] { "some string", "SoMe StriNg", "Some String", "soME STRing", "SOME STRING" },
new [] { "some string" }
},
};
}
}
[Theory]
[MemberData(nameof(GetCurrentValues_StringCollectionData))]
public void GetCurrentValues_CollectionWithModelExplorerAndViewData_ReturnsExpressionResult(
string[] rawValue,
IReadOnlyCollection<string> expected)
{
// Arrange
var metadataProvider = new TestModelMetadataProvider();
var htmlGenerator = GetGenerator(metadataProvider);
var model = new Model { Collection = { "ignored property value" } };
var viewContext = GetViewContext<Model>(model, metadataProvider);
viewContext.ViewData[nameof(Model.Collection)] = new[] { "ignored ViewData value" };
var modelExplorer =
metadataProvider.GetModelExplorerForType(typeof(List<string>), new List<string>(rawValue));
var valueProviderResult = new ValueProviderResult(
rawValue: null,
attemptedValue: null,
culture: CultureInfo.InvariantCulture);
viewContext.ModelState.SetModelValue(nameof(Model.Collection), valueProviderResult);
// Act
var result = htmlGenerator.GetCurrentValues(
viewContext,
modelExplorer,
expression: nameof(Model.Collection),
allowMultiple: true);
// Assert
Assert.Equal<string>(expected, result);
}
[Theory]
[MemberData(nameof(GetCurrentValues_StringCollectionData))]
public void GetCurrentValues_CollectionWithViewData_ReturnsViewDataEntry(
object[] rawValue,
IReadOnlyCollection<string> expected)
{
// Arrange
var metadataProvider = new TestModelMetadataProvider();
var htmlGenerator = GetGenerator(metadataProvider);
var model = new Model { Collection = { "ignored property value" } };
var viewContext = GetViewContext<Model>(model, metadataProvider);
viewContext.ViewData[nameof(Model.Collection)] = rawValue;
var valueProviderResult = new ValueProviderResult(
rawValue: null,
attemptedValue: null,
culture: CultureInfo.InvariantCulture);
viewContext.ModelState.SetModelValue(nameof(Model.Collection), valueProviderResult);
// Act
var result = htmlGenerator.GetCurrentValues(
viewContext,
modelExplorer: null,
expression: nameof(Model.Collection),
allowMultiple: true);
// Assert
Assert.Equal<string>(expected, result);
}
[Theory]
[MemberData(nameof(GetCurrentValues_StringCollectionData))]
public void GetCurrentValues_CollectionWithModel_ReturnsModel(
string[] rawValue,
IReadOnlyCollection<string> expected)
{
// Arrange
var metadataProvider = new TestModelMetadataProvider();
var htmlGenerator = GetGenerator(metadataProvider);
var model = new Model();
model.Collection.AddRange(rawValue);
var viewContext = GetViewContext<Model>(model, metadataProvider);
var valueProviderResult = new ValueProviderResult(
rawValue: null,
attemptedValue: null,
culture: CultureInfo.InvariantCulture);
viewContext.ModelState.SetModelValue(nameof(Model.Collection), valueProviderResult);
// Act
var result = htmlGenerator.GetCurrentValues(
viewContext,
modelExplorer: null,
expression: nameof(Model.Collection),
allowMultiple: true);
// Assert
Assert.Equal<string>(expected, result);
}
// property name, rawValue -> expected current values
public static TheoryData<string, object, string[]> GetCurrentValues_ValueToConvertData
{
get
{
return new TheoryData<string, object, string[]>
{
{ nameof(Model.FlagsEnum), FlagsEnum.All, new [] { "-1", "All" } },
{ nameof(Model.FlagsEnum), FlagsEnum.FortyTwo, new [] { "42", "FortyTwo" } },
{ nameof(Model.FlagsEnum), FlagsEnum.None, new [] { "0", "None" } },
{ nameof(Model.FlagsEnum), FlagsEnum.Two, new [] { "2", "Two" } },
{ nameof(Model.FlagsEnum), string.Empty, new [] { string.Empty } },
{ nameof(Model.FlagsEnum), "All", new [] { "-1", "All" } },
{ nameof(Model.FlagsEnum), "FortyTwo", new [] { "42", "FortyTwo" } },
{ nameof(Model.FlagsEnum), "None", new [] { "0", "None" } },
{ nameof(Model.FlagsEnum), "Two", new [] { "2", "Two" } },
{ nameof(Model.FlagsEnum), "Two, Four", new [] { "Two, Four", "6" } },
{ nameof(Model.FlagsEnum), "garbage", new [] { "garbage" } },
{ nameof(Model.FlagsEnum), "0", new [] { "0", "None" } },
{ nameof(Model.FlagsEnum), " 43", new [] { " 43", "43" } },
{ nameof(Model.FlagsEnum), "-5 ", new [] { "-5 ", "-5" } },
{ nameof(Model.FlagsEnum), 0, new [] { "0", "None" } },
{ nameof(Model.FlagsEnum), 1, new [] { "1", "One" } },
{ nameof(Model.FlagsEnum), 43, new [] { "43" } },
{ nameof(Model.FlagsEnum), -5, new [] { "-5" } },
{ nameof(Model.FlagsEnum), int.MaxValue, new [] { "2147483647" } },
{ nameof(Model.FlagsEnum), (uint)int.MaxValue + 1, new [] { "2147483648" } },
{ nameof(Model.FlagsEnum), uint.MaxValue, new [] { "4294967295" } }, // converted to string & used
{ nameof(Model.Id), string.Empty, new [] { string.Empty } },
{ nameof(Model.Id), "garbage", new [] { "garbage" } }, // no compatibility checks
{ nameof(Model.Id), "0", new [] { "0" } },
{ nameof(Model.Id), " 43", new [] { " 43" } },
{ nameof(Model.Id), "-5 ", new [] { "-5 " } },
{ nameof(Model.Id), 0, new [] { "0" } },
{ nameof(Model.Id), 1, new [] { "1" } },
{ nameof(Model.Id), 43, new [] { "43" } },
{ nameof(Model.Id), -5, new [] { "-5" } },
{ nameof(Model.Id), int.MaxValue, new [] { "2147483647" } },
{ nameof(Model.Id), (uint)int.MaxValue + 1, new [] { "2147483648" } }, // no limit checks
{ nameof(Model.Id), uint.MaxValue, new [] { "4294967295" } }, // no limit checks
{ nameof(Model.NullableEnum), RegularEnum.Zero, new [] { "0", "Zero" } },
{ nameof(Model.NullableEnum), RegularEnum.One, new [] { "1", "One" } },
{ nameof(Model.NullableEnum), RegularEnum.Two, new [] { "2", "Two" } },
{ nameof(Model.NullableEnum), RegularEnum.Three, new [] { "3", "Three" } },
{ nameof(Model.NullableEnum), string.Empty, new [] { string.Empty } },
{ nameof(Model.NullableEnum), "Zero", new [] { "0", "Zero" } },
{ nameof(Model.NullableEnum), "Two", new [] { "2", "Two" } },
{ nameof(Model.NullableEnum), "One, Two", new [] { "One, Two", "3", "Three" } },
{ nameof(Model.NullableEnum), "garbage", new [] { "garbage" } },
{ nameof(Model.NullableEnum), "0", new [] { "0", "Zero" } },
{ nameof(Model.NullableEnum), " 43", new [] { " 43", "43" } },
{ nameof(Model.NullableEnum), "-5 ", new [] { "-5 ", "-5" } },
{ nameof(Model.NullableEnum), 0, new [] { "0", "Zero" } },
{ nameof(Model.NullableEnum), 1, new [] { "1", "One" } },
{ nameof(Model.NullableEnum), 43, new [] { "43" } },
{ nameof(Model.NullableEnum), -5, new [] { "-5" } },
{ nameof(Model.NullableEnum), int.MaxValue, new [] { "2147483647" } },
{ nameof(Model.NullableEnum), (uint)int.MaxValue + 1, new [] { "2147483648" } },
{ nameof(Model.NullableEnum), uint.MaxValue, new [] { "4294967295" } },
{ nameof(Model.RegularEnum), RegularEnum.Zero, new [] { "0", "Zero" } },
{ nameof(Model.RegularEnum), RegularEnum.One, new [] { "1", "One" } },
{ nameof(Model.RegularEnum), RegularEnum.Two, new [] { "2", "Two" } },
{ nameof(Model.RegularEnum), RegularEnum.Three, new [] { "3", "Three" } },
{ nameof(Model.RegularEnum), string.Empty, new [] { string.Empty } },
{ nameof(Model.RegularEnum), "Zero", new [] { "0", "Zero" } },
{ nameof(Model.RegularEnum), "Two", new [] { "2", "Two" } },
{ nameof(Model.RegularEnum), "One, Two", new [] { "One, Two", "3", "Three" } },
{ nameof(Model.RegularEnum), "garbage", new [] { "garbage" } },
{ nameof(Model.RegularEnum), "0", new [] { "0", "Zero" } },
{ nameof(Model.RegularEnum), " 43", new [] { " 43", "43" } },
{ nameof(Model.RegularEnum), "-5 ", new [] { "-5 ", "-5" } },
{ nameof(Model.RegularEnum), 0, new [] { "0", "Zero" } },
{ nameof(Model.RegularEnum), 1, new [] { "1", "One" } },
{ nameof(Model.RegularEnum), 43, new [] { "43" } },
{ nameof(Model.RegularEnum), -5, new [] { "-5" } },
{ nameof(Model.RegularEnum), int.MaxValue, new [] { "2147483647" } },
{ nameof(Model.RegularEnum), (uint)int.MaxValue + 1, new [] { "2147483648" } },
{ nameof(Model.RegularEnum), uint.MaxValue, new [] { "4294967295" } },
};
}
}
[Theory]
[MemberData(nameof(GetCurrentValues_ValueToConvertData))]
public void GetCurrentValues_ValueConvertedAsExpected(
string propertyName,
object rawValue,
IReadOnlyCollection<string> expected)
{
// Arrange
var metadataProvider = TestModelMetadataProvider.CreateDefaultProvider();
var htmlGenerator = GetGenerator(metadataProvider);
var viewContext = GetViewContext<Model>(model: null, metadataProvider: metadataProvider);
var valueProviderResult = new ValueProviderResult(
rawValue,
attemptedValue: null,
culture: CultureInfo.InvariantCulture);
viewContext.ModelState.SetModelValue(propertyName, valueProviderResult);
// Act
var result = htmlGenerator.GetCurrentValues(
viewContext,
modelExplorer: null,
expression: propertyName,
allowMultiple: false);
// Assert
Assert.Equal<string>(expected, result);
}
// GetCurrentValues uses only the IModelMetadataProvider passed to the DefaultHtmlGenerator constructor.
private static IHtmlGenerator GetGenerator(IModelMetadataProvider metadataProvider)
{
var mvcOptionsAccessor = new Mock<IOptions<MvcOptions>>();
mvcOptionsAccessor.SetupGet(accessor => accessor.Options).Returns(new MvcOptions());
var htmlEncoder = Mock.Of<IHtmlEncoder>();
var dataOptionsAccessor = new Mock<IOptions<DataProtectionOptions>>();
dataOptionsAccessor.SetupGet(accessor => accessor.Options).Returns(new DataProtectionOptions());
var antiForgery = new AntiForgery(
Mock.Of<IClaimUidExtractor>(),
Mock.Of<IDataProtectionProvider>(),
Mock.Of<IAntiForgeryAdditionalDataProvider>(),
mvcOptionsAccessor.Object,
htmlEncoder,
dataOptionsAccessor.Object);
return new DefaultHtmlGenerator(
antiForgery,
Mock.Of<IScopedInstance<ActionBindingContext>>(),
metadataProvider,
Mock.Of<IUrlHelper>(),
htmlEncoder);
}
// GetCurrentValues uses only the ModelStateDictionary and ViewDataDictionary from the passed ViewContext.
private static ViewContext GetViewContext<TModel>(TModel model, IModelMetadataProvider metadataProvider)
{
var actionContext = new ActionContext();
var viewData = new ViewDataDictionary<TModel>(metadataProvider, actionContext.ModelState)
{
Model = model,
};
return new ViewContext(
actionContext,
Mock.Of<IView>(),
viewData,
Mock.Of<ITempDataDictionary>(),
TextWriter.Null);
}
public enum RegularEnum
{
Zero,
One,
Two,
Three,
}
public enum FlagsEnum
{
None = 0,
One = 1,
Two = 2,
Four = 4,
FortyTwo = 42,
All = -1,
}
private class Model
{
public int Id { get; set; }
public string Name { get; set; }
public RegularEnum RegularEnum { get; set; }
public FlagsEnum FlagsEnum { get; set; }
public RegularEnum? NullableEnum { get; set; }
public List<string> Collection { get; } = new List<string>();
}
}
}

View File

@ -243,12 +243,9 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
Assert.Equal(expectedTagName, output.TagName);
Assert.NotNull(viewContext.FormContext?.FormData);
var keyValuePair = Assert.Single(
Assert.Single(
viewContext.FormContext.FormData,
entry => entry.Key == SelectTagHelper.SelectedValuesFormDataKey);
Assert.NotNull(keyValuePair.Value);
var selectedValues = Assert.IsAssignableFrom<ICollection<string>>(keyValuePair.Value);
Assert.InRange(selectedValues.Count, 0, 1);
}
[Theory]
@ -341,12 +338,9 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
Assert.Equal(expectedTagName, output.TagName);
Assert.NotNull(viewContext.FormContext?.FormData);
var keyValuePair = Assert.Single(
Assert.Single(
viewContext.FormContext.FormData,
entry => entry.Key == SelectTagHelper.SelectedValuesFormDataKey);
Assert.NotNull(keyValuePair.Value);
var selectedValues = Assert.IsAssignableFrom<ICollection<string>>(keyValuePair.Value);
Assert.InRange(selectedValues.Count, 0, 1);
Assert.Equal(savedDisabled, items.Select(item => item.Disabled));
Assert.Equal(savedGroup, items.Select(item => item.Group));
@ -446,12 +440,9 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
Assert.Equal(expectedTagName, output.TagName);
Assert.NotNull(viewContext.FormContext?.FormData);
var keyValuePair = Assert.Single(
Assert.Single(
viewContext.FormContext.FormData,
entry => entry.Key == SelectTagHelper.SelectedValuesFormDataKey);
Assert.NotNull(keyValuePair.Value);
var selectedValues = Assert.IsAssignableFrom<ICollection<string>>(keyValuePair.Value);
Assert.InRange(selectedValues.Count, 0, 1);
Assert.Equal(savedDisabled, items.Select(item => item.Disabled));
Assert.Equal(savedGroup, items.Select(item => item.Group));
@ -504,17 +495,25 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
var modelExpression = new ModelExpression(string.Empty, modelExplorer);
viewContext.ViewData.TemplateInfo.HtmlFieldPrefix = propertyName;
ICollection<string> selectedValues = new string[0];
var currentValues = new string[0];
htmlGenerator
.Setup(real => real.GetCurrentValues(
viewContext,
modelExplorer,
string.Empty, // expression
false)) // allowMultiple
.Returns(currentValues)
.Verifiable();
htmlGenerator
.Setup(real => real.GenerateSelect(
viewContext,
modelExplorer,
null, // optionLabel
string.Empty, // name
null, // optionLabel
string.Empty, // expression
expectedItems,
false, // allowMultiple
null, // htmlAttributes
out selectedValues))
currentValues,
false, // allowMultiple
null)) // htmlAttributes
.Returns((TagBuilder)null)
.Verifiable();
@ -536,7 +535,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
var keyValuePair = Assert.Single(
viewContext.FormContext.FormData,
entry => entry.Key == SelectTagHelper.SelectedValuesFormDataKey);
Assert.Same(selectedValues, keyValuePair.Value);
Assert.Same(currentValues, keyValuePair.Value);
}
[Theory]
@ -569,17 +568,25 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
var htmlGenerator = new Mock<IHtmlGenerator>(MockBehavior.Strict);
var viewContext = TestableHtmlGenerator.GetViewContext(model, htmlGenerator.Object, metadataProvider);
ICollection<string> selectedValues = new string[0];
var currentValues = new string[0];
htmlGenerator
.Setup(real => real.GetCurrentValues(
viewContext,
modelExplorer,
propertyName, // expression
allowMultiple))
.Returns(currentValues)
.Verifiable();
htmlGenerator
.Setup(real => real.GenerateSelect(
viewContext,
modelExplorer,
null, // optionLabel
propertyName, // name
null, // optionLabel
propertyName, // expression
It.IsAny<IEnumerable<SelectListItem>>(),
currentValues,
allowMultiple,
null, // htmlAttributes
out selectedValues))
null)) // htmlAttributes
.Returns((TagBuilder)null)
.Verifiable();
@ -600,7 +607,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
var keyValuePair = Assert.Single(
viewContext.FormContext.FormData,
entry => entry.Key == SelectTagHelper.SelectedValuesFormDataKey);
Assert.Same(selectedValues, keyValuePair.Value);
Assert.Same(currentValues, keyValuePair.Value);
}
public class NameAndId