diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/FormValueProvider.cs b/src/Mvc/Mvc.Core/src/ModelBinding/FormValueProvider.cs index 1ca941c979fc..1c9c1cfde5d7 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/FormValueProvider.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/FormValueProvider.cs @@ -13,7 +13,10 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding; /// public class FormValueProvider : BindingSourceValueProvider, IEnumerableValueProvider { + internal const string CultureInvariantFieldName = "__Invariant"; + private readonly IFormCollection _values; + private readonly HashSet? _invariantValueKeys; private PrefixContainer? _prefixContainer; /// @@ -39,6 +42,12 @@ public FormValueProvider( } _values = values; + + if (_values.TryGetValue(CultureInvariantFieldName, out var invariantKeys) && invariantKeys.Count > 0) + { + _invariantValueKeys = new(invariantKeys, StringComparer.OrdinalIgnoreCase); + } + Culture = culture; } @@ -104,7 +113,8 @@ public override ValueProviderResult GetValue(string key) } else { - return new ValueProviderResult(values, Culture); + var culture = _invariantValueKeys?.Contains(key) == true ? CultureInfo.InvariantCulture : Culture; + return new ValueProviderResult(values, culture); } } } diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/JQueryFormValueProvider.cs b/src/Mvc/Mvc.Core/src/ModelBinding/JQueryFormValueProvider.cs index 26bd1f338624..a86584e3f4f4 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/JQueryFormValueProvider.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/JQueryFormValueProvider.cs @@ -13,6 +13,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding; /// public class JQueryFormValueProvider : JQueryValueProvider { + private readonly HashSet? _invariantValueKeys; + /// /// Initializes a new instance of the class. /// @@ -25,5 +27,22 @@ public JQueryFormValueProvider( CultureInfo? culture) : base(bindingSource, values, culture) { + if (values.TryGetValue(FormValueProvider.CultureInvariantFieldName, out var invariantKeys) && invariantKeys.Count > 0) + { + _invariantValueKeys = new(invariantKeys, StringComparer.OrdinalIgnoreCase); + } + } + + /// + public override ValueProviderResult GetValue(string key) + { + var result = base.GetValue(key); + + if (result.Length > 0 && _invariantValueKeys?.Contains(key) == true) + { + return new(result.Values, CultureInfo.InvariantCulture); + } + + return result; } } diff --git a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt index 9742d6caa946..b69885465ce6 100644 --- a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt +++ b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt @@ -30,6 +30,7 @@ Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.SystemTextJsonValidationMetadataP Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.SystemTextJsonValidationMetadataProvider.SystemTextJsonValidationMetadataProvider(System.Text.Json.JsonNamingPolicy! namingPolicy) -> void Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.ValidationMetadata.ValidationModelName.get -> string? Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.ValidationMetadata.ValidationModelName.set -> void +override Microsoft.AspNetCore.Mvc.ModelBinding.JQueryFormValueProvider.GetValue(string! key) -> Microsoft.AspNetCore.Mvc.ModelBinding.ValueProviderResult virtual Microsoft.AspNetCore.Mvc.Infrastructure.ConfigureCompatibilityOptions.PostConfigure(string? name, TOptions! options) -> void static Microsoft.AspNetCore.Mvc.ControllerBase.Empty.get -> Microsoft.AspNetCore.Mvc.EmptyResult! *REMOVED*virtual Microsoft.AspNetCore.Mvc.ModelBinding.DefaultPropertyFilterProvider.PropertyIncludeExpressions.get -> System.Collections.Generic.IEnumerable!>!>? diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/FormValueProviderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/FormValueProviderTest.cs index 14e87425b0de..abf0619f48cc 100644 --- a/src/Mvc/Mvc.Core/test/ModelBinding/FormValueProviderTest.cs +++ b/src/Mvc/Mvc.Core/test/ModelBinding/FormValueProviderTest.cs @@ -9,6 +9,31 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding; public class FormValueProviderTest : EnumerableValueProviderTest { + [Fact] + public void GetValue_ReturnsInvariantCulture_IfInvariantEntryExists() + { + // Arrange + var culture = new CultureInfo("fr-FR"); + var invariantCultureKey = "prefix.name"; + var currentCultureKey = "some"; + var values = new Dictionary(BackingStore) + { + { FormValueProvider.CultureInvariantFieldName, new(invariantCultureKey) }, + }; + var valueProvider = GetEnumerableValueProvider(BindingSource.Query, values, culture); + + // Act + var invariantCultureResult = valueProvider.GetValue(invariantCultureKey); + var currentCultureResult = valueProvider.GetValue(currentCultureKey); + + // Assert + Assert.Equal(CultureInfo.InvariantCulture, invariantCultureResult.Culture); + Assert.Equal(BackingStore[invariantCultureKey], invariantCultureResult.Values); + + Assert.Equal(culture, currentCultureResult.Culture); + Assert.Equal(BackingStore[currentCultureKey], currentCultureResult.Values); + } + protected override IEnumerableValueProvider GetEnumerableValueProvider( BindingSource bindingSource, Dictionary values, diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/JQueryFormValueProviderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/JQueryFormValueProviderTest.cs index 42bdafa0aba3..29d285500695 100644 --- a/src/Mvc/Mvc.Core/test/ModelBinding/JQueryFormValueProviderTest.cs +++ b/src/Mvc/Mvc.Core/test/ModelBinding/JQueryFormValueProviderTest.cs @@ -46,4 +46,29 @@ public override void GetValue_EmptyKey() // Assert Assert.Equal("some-value", (string)result); } + + [Fact] + public void GetValue_ReturnsInvariantCulture_IfInvariantEntryExists() + { + // Arrange + var culture = new CultureInfo("fr-FR"); + var invariantCultureKey = "prefix.name"; + var currentCultureKey = "some"; + var values = new Dictionary(BackingStore) + { + { FormValueProvider.CultureInvariantFieldName, new(invariantCultureKey) }, + }; + var valueProvider = GetEnumerableValueProvider(BindingSource.Query, values, culture); + + // Act + var invariantCultureResult = valueProvider.GetValue(invariantCultureKey); + var currentCultureResult = valueProvider.GetValue(currentCultureKey); + + // Assert + Assert.Equal(CultureInfo.InvariantCulture, invariantCultureResult.Culture); + Assert.Equal(BackingStore[invariantCultureKey], invariantCultureResult.Values); + + Assert.Equal(culture, currentCultureResult.Culture); + Assert.Equal(BackingStore[currentCultureKey], currentCultureResult.Values); + } } diff --git a/src/Mvc/Mvc.TagHelpers/src/InputTagHelper.cs b/src/Mvc/Mvc.TagHelpers/src/InputTagHelper.cs index b5d9c21e53b5..3e985f447290 100644 --- a/src/Mvc/Mvc.TagHelpers/src/InputTagHelper.cs +++ b/src/Mvc/Mvc.TagHelpers/src/InputTagHelper.cs @@ -243,8 +243,18 @@ public override void Process(TagHelperContext context, TagHelperOutput output) if (tagBuilder != null) { - // This TagBuilder contains the one element of interest. + // This TagBuilder contains the primary element of interest. output.MergeAttributes(tagBuilder); + + if (tagBuilder.Attributes.TryGetValue("name", out var fullName) && + ViewContext.FormContext.InvariantField(fullName)) + { + // If the value attribute used culture-invariant formatting, output a hidden + // element so the form submission includes an entry indicating such. + // This lets the model binding logic decide which CultureInfo to use when parsing form entries. + GenerateInvariantCultureMetadata(fullName, output.PostElement); + } + if (tagBuilder.HasInnerHtml) { // Since this is not the "checkbox" special-case, no guarantee that output is a self-closing @@ -410,6 +420,14 @@ private TagBuilder GenerateTextBox( htmlAttributes); } + private static void GenerateInvariantCultureMetadata(string propertyName, TagHelperContent builder) + => builder + .AppendHtml(""); + // Imitate Generator.GenerateHidden() using Generator.GenerateTextBox(). This adds support for asp-format that // is not available in Generator.GenerateHidden(). private TagBuilder GenerateHidden(ModelExplorer modelExplorer, IDictionary htmlAttributes) diff --git a/src/Mvc/Mvc.TagHelpers/test/InputTagHelperTest.cs b/src/Mvc/Mvc.TagHelpers/test/InputTagHelperTest.cs index 38900c3f2c99..3de975a8180f 100644 --- a/src/Mvc/Mvc.TagHelpers/test/InputTagHelperTest.cs +++ b/src/Mvc/Mvc.TagHelpers/test/InputTagHelperTest.cs @@ -833,6 +833,75 @@ public void Process_GeneratesFormattedOutput_ForDateTime(string specifiedType, s Assert.Equal(expectedTagName, output.TagName); } + [Theory] + [InlineData("SomeProperty", "SomeProperty", true)] + [InlineData("SomeProperty", "[0].SomeProperty", true)] + [InlineData("SomeProperty", "[0].SomeProperty", false)] + public void Process_GeneratesInvariantCultureMetadataInput_WhenValueUsesInvariantFormatting(string propertyName, string nameAttributeValue, bool usesInvariantFormatting) + { + // Arrange + var metadataProvider = new EmptyModelMetadataProvider(); + var htmlGenerator = new Mock(MockBehavior.Strict); + var model = false; + var modelExplorer = metadataProvider.GetModelExplorerForType(typeof(bool), model); + var modelExpression = new ModelExpression(name: string.Empty, modelExplorer: modelExplorer); + var viewContext = TestableHtmlGenerator.GetViewContext(model, htmlGenerator.Object, metadataProvider); + var tagHelper = new InputTagHelper(htmlGenerator.Object) + { + For = modelExpression, + InputTypeName = "text", + Name = propertyName, + ViewContext = viewContext, + }; + + var tagBuilder = new TagBuilder("input") + { + TagRenderMode = TagRenderMode.SelfClosing, + Attributes = + { + { "name", nameAttributeValue }, + }, + }; + + htmlGenerator + .Setup(mock => mock.GenerateTextBox( + tagHelper.ViewContext, + tagHelper.For.ModelExplorer, + tagHelper.For.Name, + modelExplorer.Model, + null, // format + It.IsAny())) // htmlAttributes + .Returns(tagBuilder) + .Callback(() => viewContext.FormContext.InvariantField(tagBuilder.Attributes["name"], usesInvariantFormatting)) + .Verifiable(); + + var expectedPostElement = usesInvariantFormatting + ? $"" + : string.Empty; + + var attributes = new TagHelperAttributeList + { + { "name", propertyName }, + { "type", "text" }, + }; + var context = new TagHelperContext(attributes, new Dictionary(), "test"); + var output = new TagHelperOutput( + "input", + new TagHelperAttributeList(), + getChildContentAsync: (useCachedResult, encoder) => Task.FromResult(result: null)) + { + TagMode = TagMode.SelfClosing, + }; + + // Act + tagHelper.Process(context, output); + + // Assert + htmlGenerator.Verify(); + + Assert.Equal(expectedPostElement, output.PostElement.GetContent()); + } + [Fact] public async Task ProcessAsync_GenerateCheckBox_WithHiddenInputRenderModeNone() { diff --git a/src/Mvc/Mvc.ViewFeatures/src/DefaultHtmlGenerator.cs b/src/Mvc/Mvc.ViewFeatures/src/DefaultHtmlGenerator.cs index ce1675bbddb8..caabe80c567e 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/DefaultHtmlGenerator.cs +++ b/src/Mvc/Mvc.ViewFeatures/src/DefaultHtmlGenerator.cs @@ -40,6 +40,7 @@ public class DefaultHtmlGenerator : IHtmlGenerator private readonly IUrlHelperFactory _urlHelperFactory; private readonly HtmlEncoder _htmlEncoder; private readonly ValidationHtmlAttributeProvider _validationAttributeProvider; + private readonly FormInputRenderMode _formInputRenderMode; /// /// Initializes a new instance of the class. @@ -94,6 +95,7 @@ public DefaultHtmlGenerator( _urlHelperFactory = urlHelperFactory; _htmlEncoder = htmlEncoder; _validationAttributeProvider = validationAttributeProvider; + _formInputRenderMode = optionsAccessor.Value.HtmlHelperOptions.FormInputRenderMode; // Underscores are fine characters in id's. IdAttributeDotReplacement = optionsAccessor.Value.HtmlHelperOptions.IdAttributeDotReplacement; @@ -134,7 +136,12 @@ public string Encode(object value) /// public string FormatValue(object value, string format) { - return ViewDataDictionary.FormatValue(value, format); + return ViewDataDictionary.FormatValue(value, format, CultureInfo.CurrentCulture); + } + + private static string FormatValue(object value, string format, IFormatProvider formatProvider) + { + return ViewDataDictionary.FormatValue(value, format, formatProvider); } /// @@ -1282,7 +1289,18 @@ protected virtual TagBuilder GenerateInput( AddMaxLengthAttribute(viewContext.ViewData, tagBuilder, modelExplorer, expression); } - var valueParameter = FormatValue(value, format); + CultureInfo culture; + if (ShouldUseInvariantFormattingForInputType(suppliedTypeString, viewContext.Html5DateRenderingMode)) + { + culture = CultureInfo.InvariantCulture; + viewContext.FormContext.InvariantField(fullName, true); + } + else + { + culture = CultureInfo.CurrentCulture; + } + + var valueParameter = FormatValue(value, format, culture); var usedModelState = false; switch (inputType) { @@ -1329,27 +1347,15 @@ protected virtual TagBuilder GenerateInput( case InputType.Text: default: - var attributeValue = (string)GetModelStateValue(viewContext, fullName, typeof(string)); - if (attributeValue == null) + if (string.Equals(suppliedTypeString, "file", StringComparison.OrdinalIgnoreCase) || + string.Equals(suppliedTypeString, "image", StringComparison.OrdinalIgnoreCase)) { - attributeValue = useViewData ? EvalString(viewContext, expression, format) : valueParameter; + // 'value' attribute is not needed for 'file' and 'image' input types. } - - var addValue = true; - object typeAttributeValue; - if (htmlAttributes != null && htmlAttributes.TryGetValue("type", out typeAttributeValue)) - { - var typeAttributeString = typeAttributeValue.ToString(); - if (string.Equals(typeAttributeString, "file", StringComparison.OrdinalIgnoreCase) || - string.Equals(typeAttributeString, "image", StringComparison.OrdinalIgnoreCase)) - { - // 'value' attribute is not needed for 'file' and 'image' input types. - addValue = false; - } - } - - if (addValue) + else { + var attributeValue = (string)GetModelStateValue(viewContext, fullName, typeof(string)); + attributeValue ??= useViewData ? EvalString(viewContext, expression, format) : valueParameter; tagBuilder.MergeAttribute("value", attributeValue, replaceExisting: isExplicitValue); } @@ -1556,6 +1562,38 @@ private static string GetInputTypeString(InputType inputType) } } + private bool ShouldUseInvariantFormattingForInputType(string inputType, Html5DateRenderingMode dateRenderingMode) + { + if (_formInputRenderMode == FormInputRenderMode.DetectCultureFromInputType) + { + var isNumberInput = + string.Equals(inputType, "number", StringComparison.OrdinalIgnoreCase) || + string.Equals(inputType, "range", StringComparison.OrdinalIgnoreCase); + + if (isNumberInput) + { + return true; + } + + if (dateRenderingMode != Html5DateRenderingMode.CurrentCulture) + { + var isDateInput = + string.Equals(inputType, "date", StringComparison.OrdinalIgnoreCase) || + string.Equals(inputType, "datetime-local", StringComparison.OrdinalIgnoreCase) || + string.Equals(inputType, "month", StringComparison.OrdinalIgnoreCase) || + string.Equals(inputType, "time", StringComparison.OrdinalIgnoreCase) || + string.Equals(inputType, "week", StringComparison.OrdinalIgnoreCase); + + if (isDateInput) + { + return true; + } + } + } + + return false; + } + private static IEnumerable GetSelectListItems( ViewContext viewContext, string expression) diff --git a/src/Mvc/Mvc.ViewFeatures/src/FormContext.cs b/src/Mvc/Mvc.ViewFeatures/src/FormContext.cs index 31083e19bef3..d6f78f1e278c 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/FormContext.cs +++ b/src/Mvc/Mvc.ViewFeatures/src/FormContext.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Html; +using System.Globalization; namespace Microsoft.AspNetCore.Mvc.ViewFeatures; @@ -15,6 +16,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures; public class FormContext { private Dictionary _renderedFields; + private Dictionary _invariantFields; private Dictionary _formData; private IList _endOfFormContent; @@ -107,6 +109,19 @@ private Dictionary RenderedFields } } + /// + /// Gets a dictionary mapping full HTML field names to indications that corresponding value was formatted + /// using . + /// + private Dictionary InvariantFields + { + get + { + _invariantFields ??= new(StringComparer.Ordinal); + return _invariantFields; + } + } + /// /// Returns an indication based on that the given has /// been rendered in this <form>. @@ -143,4 +158,20 @@ public void RenderedField(string fieldName, bool value) RenderedFields[fieldName] = value; } + + internal bool InvariantField(string fieldName) + { + ArgumentNullException.ThrowIfNull(fieldName); + + InvariantFields.TryGetValue(fieldName, out var result); + + return result; + } + + internal void InvariantField(string fieldName, bool value) + { + ArgumentNullException.ThrowIfNull(fieldName); + + InvariantFields[fieldName] = value; + } } diff --git a/src/Mvc/Mvc.ViewFeatures/src/HtmlHelperOptions.cs b/src/Mvc/Mvc.ViewFeatures/src/HtmlHelperOptions.cs index 5e013aef71c1..d4ce05f2811f 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/HtmlHelperOptions.cs +++ b/src/Mvc/Mvc.ViewFeatures/src/HtmlHelperOptions.cs @@ -60,4 +60,14 @@ public string IdAttributeDotReplacement /// Gets or sets the way hidden inputs are rendered for checkbox tag helpers and html helpers. /// public CheckBoxHiddenInputRenderMode CheckBoxHiddenInputRenderMode { get; set; } = CheckBoxHiddenInputRenderMode.EndOfForm; + + /// + /// Gets or sets a value that determines how form <input/> elements are rendered. + /// + /// + /// Some form elements (e.g., <input type="text"/>) require culture-specific formatting and parsing because their values are + /// directly entered by the user. However, other inputs (e.g., <input type="number"/>) use culture-invariant + /// formatting both in the HTML source and in the form request. + /// + public FormInputRenderMode FormInputRenderMode { get; set; } } diff --git a/src/Mvc/Mvc.ViewFeatures/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.ViewFeatures/src/PublicAPI.Unshipped.txt index 73d4cbd64fd1..adb19c0812e2 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/PublicAPI.Unshipped.txt +++ b/src/Mvc/Mvc.ViewFeatures/src/PublicAPI.Unshipped.txt @@ -1,3 +1,8 @@ #nullable enable *REMOVED*Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary.Model.get -> TModel? +Microsoft.AspNetCore.Mvc.Rendering.FormInputRenderMode +Microsoft.AspNetCore.Mvc.Rendering.FormInputRenderMode.AlwaysUseCurrentCulture = 1 -> Microsoft.AspNetCore.Mvc.Rendering.FormInputRenderMode +Microsoft.AspNetCore.Mvc.Rendering.FormInputRenderMode.DetectCultureFromInputType = 0 -> Microsoft.AspNetCore.Mvc.Rendering.FormInputRenderMode +Microsoft.AspNetCore.Mvc.ViewFeatures.HtmlHelperOptions.FormInputRenderMode.get -> Microsoft.AspNetCore.Mvc.Rendering.FormInputRenderMode +Microsoft.AspNetCore.Mvc.ViewFeatures.HtmlHelperOptions.FormInputRenderMode.set -> void Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary.Model.get -> TModel diff --git a/src/Mvc/Mvc.ViewFeatures/src/Rendering/FormInputRenderMode.cs b/src/Mvc/Mvc.ViewFeatures/src/Rendering/FormInputRenderMode.cs new file mode 100644 index 000000000000..6d48f96c1c03 --- /dev/null +++ b/src/Mvc/Mvc.ViewFeatures/src/Rendering/FormInputRenderMode.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; + +namespace Microsoft.AspNetCore.Mvc.Rendering; + +/// +/// Used for configuring how form inputs should be rendered with respect to +/// the current locale. +/// +public enum FormInputRenderMode +{ + /// + /// When appropriate, use to format HTML input element values. + /// Generate a hidden HTML form input for each value that uses culture-invariant formatting + /// so model binding logic can parse with the correct culture. + /// + DetectCultureFromInputType = 0, + + /// + /// Always use to format input element values. + /// + AlwaysUseCurrentCulture = 1, +} diff --git a/src/Mvc/Mvc.ViewFeatures/src/ViewDataDictionary.cs b/src/Mvc/Mvc.ViewFeatures/src/ViewDataDictionary.cs index c9b3357a563f..054575b0f47c 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/ViewDataDictionary.cs +++ b/src/Mvc/Mvc.ViewFeatures/src/ViewDataDictionary.cs @@ -391,11 +391,11 @@ public ICollection Values public string? Eval(string? expression, string? format) { var value = Eval(expression); - return FormatValue(value, format); + return FormatValue(value, format, CultureInfo.CurrentCulture); } /// - /// Formats the given using given . + /// Formats the given using the given . /// /// The value to format. /// @@ -403,6 +403,9 @@ public ICollection Values /// /// The formatted . public static string? FormatValue(object? value, string? format) + => FormatValue(value, format, CultureInfo.CurrentCulture); + + internal static string? FormatValue(object? value, string? format, IFormatProvider formatProvider) { if (value == null) { @@ -411,11 +414,11 @@ public ICollection Values if (string.IsNullOrEmpty(format)) { - return Convert.ToString(value, CultureInfo.CurrentCulture); + return Convert.ToString(value, formatProvider); } else { - return string.Format(CultureInfo.CurrentCulture, format, value); + return string.Format(formatProvider, format, value); } } diff --git a/src/Mvc/Mvc.ViewFeatures/test/DefaultHtmlGeneratorTest.cs b/src/Mvc/Mvc.ViewFeatures/test/DefaultHtmlGeneratorTest.cs index c923a7a0162b..02bc8e5a0250 100644 --- a/src/Mvc/Mvc.ViewFeatures/test/DefaultHtmlGeneratorTest.cs +++ b/src/Mvc/Mvc.ViewFeatures/test/DefaultHtmlGeneratorTest.cs @@ -327,6 +327,89 @@ public void GenerateTextBox_SearchType_RendersMaxLength(string expression, int e Assert.Equal(expectedValue, int.Parse(attribute.Value, CultureInfo.InvariantCulture)); } + // type, shouldUseInvariantFormatting, dateRenderingMode + public static TheoryData GenerateTextBox_InvariantFormattingData + { + get + { + return new TheoryData + { + {"text", Html5DateRenderingMode.Rfc3339, false }, + {"number", Html5DateRenderingMode.Rfc3339, true }, + {"range", Html5DateRenderingMode.Rfc3339, true }, + {"date", Html5DateRenderingMode.Rfc3339, true }, + {"datetime-local", Html5DateRenderingMode.Rfc3339, true }, + {"month", Html5DateRenderingMode.Rfc3339, true }, + {"time", Html5DateRenderingMode.Rfc3339, true }, + {"week", Html5DateRenderingMode.Rfc3339 , true }, + {"date", Html5DateRenderingMode.CurrentCulture, false }, + {"datetime-local", Html5DateRenderingMode.CurrentCulture, false }, + {"month", Html5DateRenderingMode.CurrentCulture, false }, + {"time", Html5DateRenderingMode.CurrentCulture, false }, + {"week", Html5DateRenderingMode.CurrentCulture, false }, + }; + } + } + + [Theory] + [MemberData(nameof(GenerateTextBox_InvariantFormattingData))] + public void GenerateTextBox_UsesCultureInvariantFormatting_ForAppropriateTypes(string type, Html5DateRenderingMode dateRenderingMode, bool shouldUseInvariantFormatting) + { + // Arrange + var metadataProvider = new TestModelMetadataProvider(); + var htmlHelperOptions = new HtmlHelperOptions() + { + Html5DateRenderingMode = dateRenderingMode, + }; + var htmlGenerator = GetGenerator(metadataProvider, new() { HtmlHelperOptions = htmlHelperOptions }); + var viewContext = GetViewContext(model: null, metadataProvider, htmlHelperOptions); + var expression = nameof(Model.Name); + var modelMetadata = metadataProvider.GetMetadataForProperty(typeof(Model), expression); + var modelExplorer = new ModelExplorer(metadataProvider, modelMetadata, null); + var htmlAttributes = new Dictionary + { + { "name", "testElement" }, + { "type", type }, + }; + + // Act + _ = htmlGenerator.GenerateTextBox(viewContext, modelExplorer, expression, null, null, htmlAttributes); + + // Assert + var didForceInvariantFormatting = viewContext.FormContext.InvariantField(expression); + Assert.Equal(shouldUseInvariantFormatting, didForceInvariantFormatting); + } + + [Theory] + [MemberData(nameof(GenerateTextBox_InvariantFormattingData))] + public void GenerateTextBox_AlwaysUsesCultureSpecificFormatting_WhenOptionIsSet(string type, Html5DateRenderingMode dateRenderingMode, bool shouldUseInvariantFormatting) + { + // Arrange + var metadataProvider = new TestModelMetadataProvider(); + var htmlHelperOptions = new HtmlHelperOptions() + { + Html5DateRenderingMode = dateRenderingMode, + FormInputRenderMode = FormInputRenderMode.AlwaysUseCurrentCulture, + }; + var htmlGenerator = GetGenerator(metadataProvider, new() { HtmlHelperOptions = htmlHelperOptions }); + var viewContext = GetViewContext(model: null, metadataProvider, htmlHelperOptions); + var expression = nameof(Model.Name); + var modelMetadata = metadataProvider.GetMetadataForProperty(typeof(Model), expression); + var modelExplorer = new ModelExplorer(metadataProvider, modelMetadata, null); + var htmlAttributes = new Dictionary + { + { "name", "testElement" }, + { "type", type }, + }; + + // Act + _ = htmlGenerator.GenerateTextBox(viewContext, modelExplorer, expression, null, null, htmlAttributes); + + // Assert + var didForceInvariantFormatting = viewContext.FormContext.InvariantField(expression); + Assert.False(didForceInvariantFormatting); + } + [Theory] [InlineData(nameof(ModelWithMaxLengthMetadata.FieldWithMaxLength))] [InlineData(nameof(ModelWithMaxLengthMetadata.FieldWithStringLength))] @@ -916,10 +999,10 @@ public void GenerateAntiforgery_AlwaysGeneratesAntiforgeryToken_IfCannotRenderAt } // GetCurrentValues uses only the IModelMetadataProvider passed to the DefaultHtmlGenerator constructor. - private static IHtmlGenerator GetGenerator(IModelMetadataProvider metadataProvider) + private static IHtmlGenerator GetGenerator(IModelMetadataProvider metadataProvider, MvcViewOptions options = default) { var mvcViewOptionsAccessor = new Mock>(); - mvcViewOptionsAccessor.SetupGet(accessor => accessor.Value).Returns(new MvcViewOptions()); + mvcViewOptionsAccessor.SetupGet(accessor => accessor.Value).Returns(options ?? new MvcViewOptions()); var htmlEncoder = Mock.Of(); var antiforgery = new Mock(); @@ -945,7 +1028,7 @@ private static IHtmlGenerator GetGenerator(IModelMetadataProvider metadataProvid } // GetCurrentValues uses only the ModelStateDictionary and ViewDataDictionary from the passed ViewContext. - private static ViewContext GetViewContext(TModel model, IModelMetadataProvider metadataProvider) + private static ViewContext GetViewContext(TModel model, IModelMetadataProvider metadataProvider, HtmlHelperOptions options = default) { var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); var viewData = new ViewDataDictionary(metadataProvider, actionContext.ModelState) @@ -959,7 +1042,7 @@ private static ViewContext GetViewContext(TModel model, IModelMetadataPr viewData, Mock.Of(), TextWriter.Null, - new HtmlHelperOptions()); + options ?? new HtmlHelperOptions()); } public enum RegularEnum diff --git a/src/Mvc/test/Mvc.FunctionalTests/HtmlGenerationTest.cs b/src/Mvc/test/Mvc.FunctionalTests/HtmlGenerationTest.cs index 426fca79e0ad..56590f1c508d 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/HtmlGenerationTest.cs +++ b/src/Mvc/test/Mvc.FunctionalTests/HtmlGenerationTest.cs @@ -111,7 +111,7 @@ public async Task HtmlGenerationWebSite_GeneratesExpectedResults(string action, responseContent = responseContent.Trim(); if (antiforgeryPath == null) { - Assert.Equal(expectedContent.Trim(), responseContent, ignoreLineEndingDifferences: true); + ResourceFile.UpdateOrVerify(_resourcesAssembly, outputFile, expectedContent, responseContent); } else { diff --git a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Customer.Index.html b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Customer.Index.html index a61f895061fc..ebb12b5a39c2 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Customer.Index.html +++ b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Customer.Index.html @@ -3,7 +3,7 @@
- + The value '' is invalid.
diff --git a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Customer.html b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Customer.html index ec748e6ea5e6..2afebd632761 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Customer.html +++ b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Customer.html @@ -3,7 +3,7 @@
- +
diff --git a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.EmployeeList.html b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.EmployeeList.html index edc86e3450de..a93298976004 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.EmployeeList.html +++ b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.EmployeeList.html @@ -4,7 +4,7 @@
- +
@@ -35,7 +35,7 @@
- +
@@ -66,7 +66,7 @@
- +
diff --git a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Order.Encoded.html b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Order.Encoded.html index 4ec3df1116dd..23dd048b6e41 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Order.Encoded.html +++ b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Order.Encoded.html @@ -1,4 +1,4 @@ - + @@ -11,7 +11,7 @@
- +
@@ -32,7 +32,7 @@ - + @@ -42,7 +42,7 @@ - + @@ -79,7 +79,7 @@
- +
diff --git a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Order.html b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Order.html index 54a6af28f824..b0916d47c052 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Order.html +++ b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Order.html @@ -11,7 +11,7 @@
- +
@@ -32,7 +32,7 @@ - + @@ -42,7 +42,7 @@ - + @@ -79,7 +79,7 @@
- +
diff --git a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Product.Encoded.html b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Product.Encoded.html index 68230861edd7..477d075a749d 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Product.Encoded.html +++ b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Product.Encoded.html @@ -1,4 +1,4 @@ - + diff --git a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.ProductList.html b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.ProductList.html index 9ad0850e700b..1434da7d175e 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.ProductList.html +++ b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.ProductList.html @@ -10,7 +10,7 @@
- +
@@ -27,7 +27,7 @@
- +
@@ -44,7 +44,7 @@
- +
diff --git a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.ProductListUsingTagHelpers.html b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.ProductListUsingTagHelpers.html index ae21e4e251ea..3a1b89dc4696 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.ProductListUsingTagHelpers.html +++ b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.ProductListUsingTagHelpers.html @@ -11,7 +11,7 @@
- +
@@ -29,7 +29,7 @@
- +
@@ -47,7 +47,7 @@
- +
diff --git a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.ProductListUsingTagHelpersWithNullModel.html b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.ProductListUsingTagHelpersWithNullModel.html index 64eaaeffdae2..9363027c26bb 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.ProductListUsingTagHelpersWithNullModel.html +++ b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.ProductListUsingTagHelpersWithNullModel.html @@ -11,7 +11,7 @@
- +
diff --git a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Warehouse.html b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Warehouse.html index d86cdb95db99..43fb90465dda 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Warehouse.html +++ b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Warehouse.html @@ -6,7 +6,7 @@

City_1

- +
diff --git a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Employee.Create.Invalid.html b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Employee.Create.Invalid.html index 77ad4a00127f..0827ba9e0c2c 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Employee.Create.Invalid.html +++ b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Employee.Create.Invalid.html @@ -23,7 +23,7 @@

Employee

- + The field Age must be between 10 and 100.
@@ -53,14 +53,14 @@

Employee

- + The JoinDate field is required.
- + The value 'z' is not valid for Salary.
diff --git a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Employee.Create.html b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Employee.Create.html index 441241eb8435..b52a0e153904 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Employee.Create.html +++ b/src/Mvc/test/Mvc.FunctionalTests/compiler/resources/TagHelpersWebSite.Employee.Create.html @@ -21,7 +21,7 @@

Employee

- +
@@ -51,14 +51,14 @@

Employee

- +
- +