Skip to content

Commit 2d953db

Browse files
Fix MVC form data binding localization (#43182)
1 parent ff444e5 commit 2d953db

27 files changed

+422
-60
lines changed

src/Mvc/Mvc.Core/src/ModelBinding/FormValueProvider.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding;
1313
/// </summary>
1414
public class FormValueProvider : BindingSourceValueProvider, IEnumerableValueProvider
1515
{
16+
internal const string CultureInvariantFieldName = "__Invariant";
17+
1618
private readonly IFormCollection _values;
19+
private readonly HashSet<string?>? _invariantValueKeys;
1720
private PrefixContainer? _prefixContainer;
1821

1922
/// <summary>
@@ -39,6 +42,12 @@ public FormValueProvider(
3942
}
4043

4144
_values = values;
45+
46+
if (_values.TryGetValue(CultureInvariantFieldName, out var invariantKeys) && invariantKeys.Count > 0)
47+
{
48+
_invariantValueKeys = new(invariantKeys, StringComparer.OrdinalIgnoreCase);
49+
}
50+
4251
Culture = culture;
4352
}
4453

@@ -104,7 +113,8 @@ public override ValueProviderResult GetValue(string key)
104113
}
105114
else
106115
{
107-
return new ValueProviderResult(values, Culture);
116+
var culture = _invariantValueKeys?.Contains(key) == true ? CultureInfo.InvariantCulture : Culture;
117+
return new ValueProviderResult(values, culture);
108118
}
109119
}
110120
}

src/Mvc/Mvc.Core/src/ModelBinding/JQueryFormValueProvider.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding;
1313
/// </summary>
1414
public class JQueryFormValueProvider : JQueryValueProvider
1515
{
16+
private readonly HashSet<string?>? _invariantValueKeys;
17+
1618
/// <summary>
1719
/// Initializes a new instance of the <see cref="JQueryFormValueProvider"/> class.
1820
/// </summary>
@@ -25,5 +27,22 @@ public JQueryFormValueProvider(
2527
CultureInfo? culture)
2628
: base(bindingSource, values, culture)
2729
{
30+
if (values.TryGetValue(FormValueProvider.CultureInvariantFieldName, out var invariantKeys) && invariantKeys.Count > 0)
31+
{
32+
_invariantValueKeys = new(invariantKeys, StringComparer.OrdinalIgnoreCase);
33+
}
34+
}
35+
36+
/// <inheritdoc/>
37+
public override ValueProviderResult GetValue(string key)
38+
{
39+
var result = base.GetValue(key);
40+
41+
if (result.Length > 0 && _invariantValueKeys?.Contains(key) == true)
42+
{
43+
return new(result.Values, CultureInfo.InvariantCulture);
44+
}
45+
46+
return result;
2847
}
2948
}

src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.SystemTextJsonValidationMetadataP
3030
Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.SystemTextJsonValidationMetadataProvider.SystemTextJsonValidationMetadataProvider(System.Text.Json.JsonNamingPolicy! namingPolicy) -> void
3131
Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.ValidationMetadata.ValidationModelName.get -> string?
3232
Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.ValidationMetadata.ValidationModelName.set -> void
33+
override Microsoft.AspNetCore.Mvc.ModelBinding.JQueryFormValueProvider.GetValue(string! key) -> Microsoft.AspNetCore.Mvc.ModelBinding.ValueProviderResult
3334
virtual Microsoft.AspNetCore.Mvc.Infrastructure.ConfigureCompatibilityOptions<TOptions>.PostConfigure(string? name, TOptions! options) -> void
3435
static Microsoft.AspNetCore.Mvc.ControllerBase.Empty.get -> Microsoft.AspNetCore.Mvc.EmptyResult!
3536
*REMOVED*virtual Microsoft.AspNetCore.Mvc.ModelBinding.DefaultPropertyFilterProvider<TModel>.PropertyIncludeExpressions.get -> System.Collections.Generic.IEnumerable<System.Linq.Expressions.Expression<System.Func<TModel!, object!>!>!>?

src/Mvc/Mvc.Core/test/ModelBinding/FormValueProviderTest.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,31 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding;
99

1010
public class FormValueProviderTest : EnumerableValueProviderTest
1111
{
12+
[Fact]
13+
public void GetValue_ReturnsInvariantCulture_IfInvariantEntryExists()
14+
{
15+
// Arrange
16+
var culture = new CultureInfo("fr-FR");
17+
var invariantCultureKey = "prefix.name";
18+
var currentCultureKey = "some";
19+
var values = new Dictionary<string, StringValues>(BackingStore)
20+
{
21+
{ FormValueProvider.CultureInvariantFieldName, new(invariantCultureKey) },
22+
};
23+
var valueProvider = GetEnumerableValueProvider(BindingSource.Query, values, culture);
24+
25+
// Act
26+
var invariantCultureResult = valueProvider.GetValue(invariantCultureKey);
27+
var currentCultureResult = valueProvider.GetValue(currentCultureKey);
28+
29+
// Assert
30+
Assert.Equal(CultureInfo.InvariantCulture, invariantCultureResult.Culture);
31+
Assert.Equal(BackingStore[invariantCultureKey], invariantCultureResult.Values);
32+
33+
Assert.Equal(culture, currentCultureResult.Culture);
34+
Assert.Equal(BackingStore[currentCultureKey], currentCultureResult.Values);
35+
}
36+
1237
protected override IEnumerableValueProvider GetEnumerableValueProvider(
1338
BindingSource bindingSource,
1439
Dictionary<string, StringValues> values,

src/Mvc/Mvc.Core/test/ModelBinding/JQueryFormValueProviderTest.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,29 @@ public override void GetValue_EmptyKey()
4646
// Assert
4747
Assert.Equal("some-value", (string)result);
4848
}
49+
50+
[Fact]
51+
public void GetValue_ReturnsInvariantCulture_IfInvariantEntryExists()
52+
{
53+
// Arrange
54+
var culture = new CultureInfo("fr-FR");
55+
var invariantCultureKey = "prefix.name";
56+
var currentCultureKey = "some";
57+
var values = new Dictionary<string, StringValues>(BackingStore)
58+
{
59+
{ FormValueProvider.CultureInvariantFieldName, new(invariantCultureKey) },
60+
};
61+
var valueProvider = GetEnumerableValueProvider(BindingSource.Query, values, culture);
62+
63+
// Act
64+
var invariantCultureResult = valueProvider.GetValue(invariantCultureKey);
65+
var currentCultureResult = valueProvider.GetValue(currentCultureKey);
66+
67+
// Assert
68+
Assert.Equal(CultureInfo.InvariantCulture, invariantCultureResult.Culture);
69+
Assert.Equal(BackingStore[invariantCultureKey], invariantCultureResult.Values);
70+
71+
Assert.Equal(culture, currentCultureResult.Culture);
72+
Assert.Equal(BackingStore[currentCultureKey], currentCultureResult.Values);
73+
}
4974
}

src/Mvc/Mvc.TagHelpers/src/InputTagHelper.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,8 +243,18 @@ public override void Process(TagHelperContext context, TagHelperOutput output)
243243

244244
if (tagBuilder != null)
245245
{
246-
// This TagBuilder contains the one <input/> element of interest.
246+
// This TagBuilder contains the primary <input/> element of interest.
247247
output.MergeAttributes(tagBuilder);
248+
249+
if (tagBuilder.Attributes.TryGetValue("name", out var fullName) &&
250+
ViewContext.FormContext.InvariantField(fullName))
251+
{
252+
// If the value attribute used culture-invariant formatting, output a hidden
253+
// <input/> element so the form submission includes an entry indicating such.
254+
// This lets the model binding logic decide which CultureInfo to use when parsing form entries.
255+
GenerateInvariantCultureMetadata(fullName, output.PostElement);
256+
}
257+
248258
if (tagBuilder.HasInnerHtml)
249259
{
250260
// Since this is not the "checkbox" special-case, no guarantee that output is a self-closing
@@ -410,6 +420,14 @@ private TagBuilder GenerateTextBox(
410420
htmlAttributes);
411421
}
412422

423+
private static void GenerateInvariantCultureMetadata(string propertyName, TagHelperContent builder)
424+
=> builder
425+
.AppendHtml("<input name=\"")
426+
.Append(FormValueProvider.CultureInvariantFieldName)
427+
.AppendHtml("\" type=\"hidden\" value=\"")
428+
.Append(propertyName)
429+
.AppendHtml("\" />");
430+
413431
// Imitate Generator.GenerateHidden() using Generator.GenerateTextBox(). This adds support for asp-format that
414432
// is not available in Generator.GenerateHidden().
415433
private TagBuilder GenerateHidden(ModelExplorer modelExplorer, IDictionary<string, object> htmlAttributes)

src/Mvc/Mvc.TagHelpers/test/InputTagHelperTest.cs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -833,6 +833,75 @@ public void Process_GeneratesFormattedOutput_ForDateTime(string specifiedType, s
833833
Assert.Equal(expectedTagName, output.TagName);
834834
}
835835

836+
[Theory]
837+
[InlineData("SomeProperty", "SomeProperty", true)]
838+
[InlineData("SomeProperty", "[0].SomeProperty", true)]
839+
[InlineData("SomeProperty", "[0].SomeProperty", false)]
840+
public void Process_GeneratesInvariantCultureMetadataInput_WhenValueUsesInvariantFormatting(string propertyName, string nameAttributeValue, bool usesInvariantFormatting)
841+
{
842+
// Arrange
843+
var metadataProvider = new EmptyModelMetadataProvider();
844+
var htmlGenerator = new Mock<IHtmlGenerator>(MockBehavior.Strict);
845+
var model = false;
846+
var modelExplorer = metadataProvider.GetModelExplorerForType(typeof(bool), model);
847+
var modelExpression = new ModelExpression(name: string.Empty, modelExplorer: modelExplorer);
848+
var viewContext = TestableHtmlGenerator.GetViewContext(model, htmlGenerator.Object, metadataProvider);
849+
var tagHelper = new InputTagHelper(htmlGenerator.Object)
850+
{
851+
For = modelExpression,
852+
InputTypeName = "text",
853+
Name = propertyName,
854+
ViewContext = viewContext,
855+
};
856+
857+
var tagBuilder = new TagBuilder("input")
858+
{
859+
TagRenderMode = TagRenderMode.SelfClosing,
860+
Attributes =
861+
{
862+
{ "name", nameAttributeValue },
863+
},
864+
};
865+
866+
htmlGenerator
867+
.Setup(mock => mock.GenerateTextBox(
868+
tagHelper.ViewContext,
869+
tagHelper.For.ModelExplorer,
870+
tagHelper.For.Name,
871+
modelExplorer.Model,
872+
null, // format
873+
It.IsAny<object>())) // htmlAttributes
874+
.Returns(tagBuilder)
875+
.Callback(() => viewContext.FormContext.InvariantField(tagBuilder.Attributes["name"], usesInvariantFormatting))
876+
.Verifiable();
877+
878+
var expectedPostElement = usesInvariantFormatting
879+
? $"<input name=\"__Invariant\" type=\"hidden\" value=\"{tagBuilder.Attributes["name"]}\" />"
880+
: string.Empty;
881+
882+
var attributes = new TagHelperAttributeList
883+
{
884+
{ "name", propertyName },
885+
{ "type", "text" },
886+
};
887+
var context = new TagHelperContext(attributes, new Dictionary<object, object>(), "test");
888+
var output = new TagHelperOutput(
889+
"input",
890+
new TagHelperAttributeList(),
891+
getChildContentAsync: (useCachedResult, encoder) => Task.FromResult<TagHelperContent>(result: null))
892+
{
893+
TagMode = TagMode.SelfClosing,
894+
};
895+
896+
// Act
897+
tagHelper.Process(context, output);
898+
899+
// Assert
900+
htmlGenerator.Verify();
901+
902+
Assert.Equal(expectedPostElement, output.PostElement.GetContent());
903+
}
904+
836905
[Fact]
837906
public async Task ProcessAsync_GenerateCheckBox_WithHiddenInputRenderModeNone()
838907
{

src/Mvc/Mvc.ViewFeatures/src/DefaultHtmlGenerator.cs

Lines changed: 58 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ public class DefaultHtmlGenerator : IHtmlGenerator
4040
private readonly IUrlHelperFactory _urlHelperFactory;
4141
private readonly HtmlEncoder _htmlEncoder;
4242
private readonly ValidationHtmlAttributeProvider _validationAttributeProvider;
43+
private readonly FormInputRenderMode _formInputRenderMode;
4344

4445
/// <summary>
4546
/// Initializes a new instance of the <see cref="DefaultHtmlGenerator"/> class.
@@ -94,6 +95,7 @@ public DefaultHtmlGenerator(
9495
_urlHelperFactory = urlHelperFactory;
9596
_htmlEncoder = htmlEncoder;
9697
_validationAttributeProvider = validationAttributeProvider;
98+
_formInputRenderMode = optionsAccessor.Value.HtmlHelperOptions.FormInputRenderMode;
9799

98100
// Underscores are fine characters in id's.
99101
IdAttributeDotReplacement = optionsAccessor.Value.HtmlHelperOptions.IdAttributeDotReplacement;
@@ -134,7 +136,12 @@ public string Encode(object value)
134136
/// <inheritdoc />
135137
public string FormatValue(object value, string format)
136138
{
137-
return ViewDataDictionary.FormatValue(value, format);
139+
return ViewDataDictionary.FormatValue(value, format, CultureInfo.CurrentCulture);
140+
}
141+
142+
private static string FormatValue(object value, string format, IFormatProvider formatProvider)
143+
{
144+
return ViewDataDictionary.FormatValue(value, format, formatProvider);
138145
}
139146

140147
/// <inheritdoc />
@@ -1282,7 +1289,18 @@ protected virtual TagBuilder GenerateInput(
12821289
AddMaxLengthAttribute(viewContext.ViewData, tagBuilder, modelExplorer, expression);
12831290
}
12841291

1285-
var valueParameter = FormatValue(value, format);
1292+
CultureInfo culture;
1293+
if (ShouldUseInvariantFormattingForInputType(suppliedTypeString, viewContext.Html5DateRenderingMode))
1294+
{
1295+
culture = CultureInfo.InvariantCulture;
1296+
viewContext.FormContext.InvariantField(fullName, true);
1297+
}
1298+
else
1299+
{
1300+
culture = CultureInfo.CurrentCulture;
1301+
}
1302+
1303+
var valueParameter = FormatValue(value, format, culture);
12861304
var usedModelState = false;
12871305
switch (inputType)
12881306
{
@@ -1329,27 +1347,15 @@ protected virtual TagBuilder GenerateInput(
13291347

13301348
case InputType.Text:
13311349
default:
1332-
var attributeValue = (string)GetModelStateValue(viewContext, fullName, typeof(string));
1333-
if (attributeValue == null)
1350+
if (string.Equals(suppliedTypeString, "file", StringComparison.OrdinalIgnoreCase) ||
1351+
string.Equals(suppliedTypeString, "image", StringComparison.OrdinalIgnoreCase))
13341352
{
1335-
attributeValue = useViewData ? EvalString(viewContext, expression, format) : valueParameter;
1353+
// 'value' attribute is not needed for 'file' and 'image' input types.
13361354
}
1337-
1338-
var addValue = true;
1339-
object typeAttributeValue;
1340-
if (htmlAttributes != null && htmlAttributes.TryGetValue("type", out typeAttributeValue))
1341-
{
1342-
var typeAttributeString = typeAttributeValue.ToString();
1343-
if (string.Equals(typeAttributeString, "file", StringComparison.OrdinalIgnoreCase) ||
1344-
string.Equals(typeAttributeString, "image", StringComparison.OrdinalIgnoreCase))
1345-
{
1346-
// 'value' attribute is not needed for 'file' and 'image' input types.
1347-
addValue = false;
1348-
}
1349-
}
1350-
1351-
if (addValue)
1355+
else
13521356
{
1357+
var attributeValue = (string)GetModelStateValue(viewContext, fullName, typeof(string));
1358+
attributeValue ??= useViewData ? EvalString(viewContext, expression, format) : valueParameter;
13531359
tagBuilder.MergeAttribute("value", attributeValue, replaceExisting: isExplicitValue);
13541360
}
13551361

@@ -1556,6 +1562,38 @@ private static string GetInputTypeString(InputType inputType)
15561562
}
15571563
}
15581564

1565+
private bool ShouldUseInvariantFormattingForInputType(string inputType, Html5DateRenderingMode dateRenderingMode)
1566+
{
1567+
if (_formInputRenderMode == FormInputRenderMode.DetectCultureFromInputType)
1568+
{
1569+
var isNumberInput =
1570+
string.Equals(inputType, "number", StringComparison.OrdinalIgnoreCase) ||
1571+
string.Equals(inputType, "range", StringComparison.OrdinalIgnoreCase);
1572+
1573+
if (isNumberInput)
1574+
{
1575+
return true;
1576+
}
1577+
1578+
if (dateRenderingMode != Html5DateRenderingMode.CurrentCulture)
1579+
{
1580+
var isDateInput =
1581+
string.Equals(inputType, "date", StringComparison.OrdinalIgnoreCase) ||
1582+
string.Equals(inputType, "datetime-local", StringComparison.OrdinalIgnoreCase) ||
1583+
string.Equals(inputType, "month", StringComparison.OrdinalIgnoreCase) ||
1584+
string.Equals(inputType, "time", StringComparison.OrdinalIgnoreCase) ||
1585+
string.Equals(inputType, "week", StringComparison.OrdinalIgnoreCase);
1586+
1587+
if (isDateInput)
1588+
{
1589+
return true;
1590+
}
1591+
}
1592+
}
1593+
1594+
return false;
1595+
}
1596+
15591597
private static IEnumerable<SelectListItem> GetSelectListItems(
15601598
ViewContext viewContext,
15611599
string expression)

0 commit comments

Comments
 (0)