diff --git a/src/Components/Endpoints/src/FormMapping/Converters/NullableConverter.cs b/src/Components/Endpoints/src/FormMapping/Converters/NullableConverter.cs index 58bd47c60396..0285ded3902a 100644 --- a/src/Components/Endpoints/src/FormMapping/Converters/NullableConverter.cs +++ b/src/Components/Endpoints/src/FormMapping/Converters/NullableConverter.cs @@ -14,6 +14,15 @@ public bool CanConvertSingleValue() => _nonNullableConverter is ISingleValueConv public bool TryConvertValue(ref FormDataReader reader, string value, out T? result) { + if (string.IsNullOrEmpty(value)) + { + // Form post sends empty string for a form field that does not have a value, + // in case of nullable value types, that should be treated as null and + // should not be parsed for its underlying type + result = null; + return true; + } + var converter = (ISingleValueConverter)_nonNullableConverter; if (converter.TryConvertValue(ref reader, value, out var converted)) @@ -30,17 +39,20 @@ public bool TryConvertValue(ref FormDataReader reader, string value, out T? resu [RequiresDynamicCode(FormMappingHelpers.RequiresDynamicCodeMessage)] [RequiresUnreferencedCode(FormMappingHelpers.RequiresUnreferencedCodeMessage)] - internal override bool TryRead(ref FormDataReader context, Type type, FormDataMapperOptions options, out T? result, out bool found) + internal override bool TryRead(ref FormDataReader reader, Type type, FormDataMapperOptions options, out T? result, out bool found) { - if (!(_nonNullableConverter.TryRead(ref context, type, options, out var innerResult, out found) && found)) + // Donot call non-nullable converter's TryRead method, it will fail to parse empty + // string. Call the TryConvertValue method above (similar to ParsableConverter) so + // that it can handle the empty string correctly + found = reader.TryGetValue(out var value); + if (!found) { - result = null; - return false; + result = default; + return true; } else { - result = innerResult; - return true; + return TryConvertValue(ref reader, value!, out result!); } } } diff --git a/src/Components/Endpoints/test/FormMapping/Converters/NullableConverterTests.cs b/src/Components/Endpoints/test/FormMapping/Converters/NullableConverterTests.cs new file mode 100644 index 000000000000..f091fad51b3c --- /dev/null +++ b/src/Components/Endpoints/test/FormMapping/Converters/NullableConverterTests.cs @@ -0,0 +1,150 @@ +// 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; +using Microsoft.AspNetCore.Components.Endpoints.FormMapping; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Components.Endpoints.Tests.FormMapping; + +public class NullableConverterTests +{ + [Fact] + public void TryConvertValue_ForDateOnlyReturnsTrueWithNullForEmptyString() + { + var culture = CultureInfo.GetCultureInfo("en-US"); + + var nullableConverter = new NullableConverter(new ParsableConverter()); + var reader = new FormDataReader(default, culture, default); + + var returnValue = nullableConverter.TryConvertValue(ref reader, string.Empty, out var result); + + Assert.True(returnValue); + Assert.Null(result); + } + + [Fact] + public void TryConvertValue_ForDateOnlyReturnsTrueWithDateForRealDateValue() + { + var date = new DateOnly(2023, 11, 30); + var culture = CultureInfo.GetCultureInfo("en-US"); + + var nullableConverter = new NullableConverter(new ParsableConverter()); + var reader = new FormDataReader(default, culture, default); + + var returnValue = nullableConverter.TryConvertValue(ref reader, date.ToString(culture), out var result); + + Assert.True(returnValue); + Assert.Equal(date, result); + } + + [Fact] + public void TryConvertValue_ForDateOnlyReturnsFalseWithNullForBadDateValue() + { + var culture = CultureInfo.GetCultureInfo("en-US"); + + var nullableConverter = new NullableConverter(new ParsableConverter()); + var reader = new FormDataReader(default, culture, default) + { + ErrorHandler = (_, __, ___) => { } + }; + + var returnValue = nullableConverter.TryConvertValue(ref reader, "bad date", out var result); + + Assert.False(returnValue); + Assert.Null(result); + } + + [Fact] + public void TryRead_ForDateOnlyReturnsFalseWithNullForNoValue() + { + const string prefixName = "field"; + var culture = CultureInfo.GetCultureInfo("en-US"); + + var dictionary = new Dictionary(); + var buffer = prefixName.ToCharArray().AsMemory(); + var reader = new FormDataReader(dictionary, culture, buffer); + reader.PushPrefix(prefixName); + + var nullableConverter = new NullableConverter(new ParsableConverter()); + + var returnValue = nullableConverter.TryRead(ref reader, typeof(DateOnly?), default, out var result, out var found); + + Assert.False(found); + Assert.True(returnValue); + Assert.Null(result); + } + + [Fact] + public void TryRead_ForDateOnlyReturnsTrueWithNullForEmptyString() + { + const string prefixName = "field"; + var culture = CultureInfo.GetCultureInfo("en-US"); + + var dictionary = new Dictionary() + { + { new FormKey(prefixName.AsMemory()), (StringValues)string.Empty } + }; + var buffer = prefixName.ToCharArray().AsMemory(); + var reader = new FormDataReader(dictionary, culture, buffer); + reader.PushPrefix(prefixName); + + var nullableConverter = new NullableConverter(new ParsableConverter()); + + var returnValue = nullableConverter.TryRead(ref reader, typeof(DateOnly?), default, out var result, out var found); + + Assert.True(found); + Assert.True(returnValue); + Assert.Null(result); + } + + [Fact] + public void TryRead_ForDateOnlyReturnsTrueWithDateForRealDateValue() + { + const string prefixName = "field"; + var date = new DateOnly(2023, 11, 30); + var culture = CultureInfo.GetCultureInfo("en-US"); + + var dictionary = new Dictionary() + { + { new FormKey(prefixName.AsMemory()), (StringValues)date.ToString(culture) } + }; + var buffer = prefixName.ToCharArray().AsMemory(); + var reader = new FormDataReader(dictionary, culture, buffer); + reader.PushPrefix(prefixName); + + var nullableConverter = new NullableConverter(new ParsableConverter()); + + var returnValue = nullableConverter.TryRead(ref reader, typeof(DateOnly?), default, out var result, out var found); + + Assert.True(found); + Assert.True(returnValue); + Assert.Equal(date, result); + } + + [Fact] + public void TryRead_ForDateOnlyReturnsFalseWithNullForBadDateValue() + { + const string prefixName = "field"; + var culture = CultureInfo.GetCultureInfo("en-US"); + + var dictionary = new Dictionary() + { + { new FormKey(prefixName.AsMemory()), (StringValues)"bad date" } + }; + var buffer = prefixName.ToCharArray().AsMemory(); + var reader = new FormDataReader(dictionary, culture, buffer) + { + ErrorHandler = (_, __, ___) => { } + }; + reader.PushPrefix(prefixName); + + var nullableConverter = new NullableConverter(new ParsableConverter()); + + var returnValue = nullableConverter.TryRead(ref reader, typeof(DateOnly?), default, out var result, out var found); + + Assert.True(found); + Assert.False(returnValue); + Assert.Null(result); + } +} diff --git a/src/Components/test/E2ETest/ServerRenderingTests/FormHandlingTests/FormWithParentBindingContextTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/FormHandlingTests/FormWithParentBindingContextTest.cs index 06d57a454b7f..10730640ab49 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/FormHandlingTests/FormWithParentBindingContextTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/FormHandlingTests/FormWithParentBindingContextTest.cs @@ -772,6 +772,29 @@ public void CanDispatchToFormDefinedInNonPageComponent(bool suppressEnhancedNavi DispatchToFormCore(dispatchToForm); } + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("01/01/2000")] + public void FormWithNullableDateTime(string value) + { + var dispatchToForm = new DispatchToForm(this) + { + Url = "forms/with-nullable-datetime", + FormCssSelector = "form[id=nullable]", + ExpectedHandlerValue = "nullable-datetime-testform", + InputFieldId = "Id", + InputFieldCssSelector = "form[id=nullable] input[id=datetime]", + InputFieldValue = value, + AssertErrors = errors => + { + Assert.Empty(errors); + }, + }; + + DispatchToFormCore(dispatchToForm); + } + [Fact] public void CanRenderAmbiguousForms() { diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/FormWithNullableDateTime.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/FormWithNullableDateTime.razor new file mode 100644 index 000000000000..dd17d19ba6e0 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/FormWithNullableDateTime.razor @@ -0,0 +1,42 @@ +@page "/forms/with-nullable-datetime" +@using Microsoft.AspNetCore.Components.Forms +

Edit Form With Nullable DateTime

+ + +
+ + +
+ +
+@code { + [SupplyParameterFromForm] + public FormObject Model + { + get; + set; + } + + protected override void OnInitialized() + { + base.OnInitialized(); + + this.Model ??= new FormObject(); + } + + private void HandleSubmit() + { + } + + public class FormObject + { + public DateTime? NullableDateTime + { + get; + set; + } + }; +}