diff --git a/src/Components/Authorization/test/AuthorizeRouteViewTest.cs b/src/Components/Authorization/test/AuthorizeRouteViewTest.cs
index c099b0c91063..05609873187d 100644
--- a/src/Components/Authorization/test/AuthorizeRouteViewTest.cs
+++ b/src/Components/Authorization/test/AuthorizeRouteViewTest.cs
@@ -478,6 +478,11 @@ public bool CanBind(string formName, Type valueType)
return false;
}
+ public bool CanConvertSingleValue(Type type)
+ {
+ return false;
+ }
+
public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue)
{
boundValue = null;
diff --git a/src/Components/Components/src/Binding/CascadingModelBinder.cs b/src/Components/Components/src/Binding/CascadingModelBinder.cs
index cdf6d4d2e755..994e8430c3d6 100644
--- a/src/Components/Components/src/Binding/CascadingModelBinder.cs
+++ b/src/Components/Components/src/Binding/CascadingModelBinder.cs
@@ -114,7 +114,7 @@ internal void UpdateBindingInformation(string url)
var bindingContext = _bindingContext != null &&
string.Equals(_bindingContext.Name, name, StringComparison.Ordinal) &&
string.Equals(_bindingContext.BindingContextId, bindingId, StringComparison.Ordinal) ?
- _bindingContext : new ModelBindingContext(name, bindingId);
+ _bindingContext : new ModelBindingContext(name, bindingId, FormValueSupplier.CanConvertSingleValue);
// It doesn't matter that we don't check IsFixed, since the CascadingValue we are setting up will throw if the app changes.
if (IsFixed && _bindingContext != null && _bindingContext != bindingContext)
diff --git a/src/Components/Components/src/Binding/IFormValueSupplier.cs b/src/Components/Components/src/Binding/IFormValueSupplier.cs
index 364f4041b362..b8696a224339 100644
--- a/src/Components/Components/src/Binding/IFormValueSupplier.cs
+++ b/src/Components/Components/src/Binding/IFormValueSupplier.cs
@@ -18,6 +18,14 @@ public interface IFormValueSupplier
/// true if the value type can be bound; otherwise, false.
bool CanBind(string formName, Type valueType);
+ ///
+ /// Determines whether a given can be converted from a single string value.
+ /// For example, strings, numbers, boolean values, enums, guids, etc. fall in this category.
+ ///
+ /// The to check.
+ /// true if the type can be converted from a single string value; otherwise, false.
+ bool CanConvertSingleValue(Type type);
+
///
/// Tries to bind the form with the specified name to a value of the specified type.
///
diff --git a/src/Components/Components/src/Binding/ModelBindingContext.cs b/src/Components/Components/src/Binding/ModelBindingContext.cs
index 653ffdcf068d..75be17857de8 100644
--- a/src/Components/Components/src/Binding/ModelBindingContext.cs
+++ b/src/Components/Components/src/Binding/ModelBindingContext.cs
@@ -8,10 +8,13 @@ namespace Microsoft.AspNetCore.Components;
///
public sealed class ModelBindingContext
{
- internal ModelBindingContext(string name, string bindingContextId)
+ private readonly Predicate _canBind;
+
+ internal ModelBindingContext(string name, string bindingContextId, Predicate canBind)
{
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(bindingContextId);
+ ArgumentNullException.ThrowIfNull(canBind);
// We are initializing the root context, that can be a "named" root context, or the default context.
// A named root context only provides a name, and that acts as the BindingId
// A "default" root context does not provide a name, and instead it provides an explicit Binding ID.
@@ -23,6 +26,7 @@ internal ModelBindingContext(string name, string bindingContextId)
Name = name;
BindingContextId = bindingContextId ?? name;
+ _canBind = canBind;
}
///
@@ -37,4 +41,9 @@ internal ModelBindingContext(string name, string bindingContextId)
internal static string Combine(ModelBindingContext? parentContext, string name) =>
string.IsNullOrEmpty(parentContext?.Name) ? name : $"{parentContext.Name}.{name}";
+
+ internal bool CanConvert(Type type)
+ {
+ return _canBind(type);
+ }
}
diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt
index 7fe8a6a3519c..625c0e05e595 100644
--- a/src/Components/Components/src/PublicAPI.Unshipped.txt
+++ b/src/Components/Components/src/PublicAPI.Unshipped.txt
@@ -2,6 +2,7 @@
abstract Microsoft.AspNetCore.Components.RenderModeAttribute.Mode.get -> Microsoft.AspNetCore.Components.IComponentRenderMode!
Microsoft.AspNetCore.Components.Binding.IFormValueSupplier
Microsoft.AspNetCore.Components.Binding.IFormValueSupplier.CanBind(string! formName, System.Type! valueType) -> bool
+Microsoft.AspNetCore.Components.Binding.IFormValueSupplier.CanConvertSingleValue(System.Type! type) -> bool
Microsoft.AspNetCore.Components.Binding.IFormValueSupplier.TryBind(string! formName, System.Type! valueType, out object? boundValue) -> bool
Microsoft.AspNetCore.Components.CascadingModelBinder
Microsoft.AspNetCore.Components.CascadingModelBinder.CascadingModelBinder() -> void
diff --git a/src/Components/Components/test/CascadingModelBinderTest.cs b/src/Components/Components/test/CascadingModelBinderTest.cs
index 5bdd96417b86..c20703b51d8f 100644
--- a/src/Components/Components/test/CascadingModelBinderTest.cs
+++ b/src/Components/Components/test/CascadingModelBinderTest.cs
@@ -338,6 +338,11 @@ public bool CanBind(string formName, Type valueType)
return false;
}
+ public bool CanConvertSingleValue(Type type)
+ {
+ return false;
+ }
+
public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue)
{
boundValue = null;
diff --git a/src/Components/Components/test/CascadingParameterStateTest.cs b/src/Components/Components/test/CascadingParameterStateTest.cs
index 95f1201137db..6edf09f31e21 100644
--- a/src/Components/Components/test/CascadingParameterStateTest.cs
+++ b/src/Components/Components/test/CascadingParameterStateTest.cs
@@ -535,6 +535,11 @@ public bool CanBind(string formName, Type valueType)
valueType == ValueType;
}
+ public bool CanConvertSingleValue(Type type)
+ {
+ return type == ValueType;
+ }
+
public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue)
{
boundValue = BoundValue;
diff --git a/src/Components/Components/test/ModelBindingContextTest.cs b/src/Components/Components/test/ModelBindingContextTest.cs
index 4f82bdc4290d..76742c34cef2 100644
--- a/src/Components/Components/test/ModelBindingContextTest.cs
+++ b/src/Components/Components/test/ModelBindingContextTest.cs
@@ -8,7 +8,7 @@ public class ModelBindingContextTest
[Fact]
public void CanCreate_BindingContext_WithDefaultName()
{
- var context = new ModelBindingContext("", "");
+ var context = new ModelBindingContext("", "", t => true);
Assert.Equal("", context.Name);
Assert.Equal("", context.BindingContextId);
}
@@ -16,7 +16,7 @@ public void CanCreate_BindingContext_WithDefaultName()
[Fact]
public void CanCreate_BindingContext_WithName()
{
- var context = new ModelBindingContext("name", "path?handler=name");
+ var context = new ModelBindingContext("name", "path?handler=name", t => true);
Assert.Equal("name", context.Name);
Assert.Equal("path?handler=name", context.BindingContextId);
}
@@ -24,14 +24,14 @@ public void CanCreate_BindingContext_WithName()
[Fact]
public void Throws_WhenNameIsProvided_AndNoBindingContextId()
{
- var exception = Assert.Throws(() => new ModelBindingContext("name", ""));
+ var exception = Assert.Throws(() => new ModelBindingContext("name", "", t => true));
Assert.Equal("A root binding context needs to provide a name and explicit binding context id or none.", exception.Message);
}
[Fact]
public void Throws_WhenBindingContextId_IsProvidedForDefaultName()
{
- var exception = Assert.Throws(() => new ModelBindingContext("", "context"));
+ var exception = Assert.Throws(() => new ModelBindingContext("", "context", t => true));
Assert.Equal("A root binding context needs to provide a name and explicit binding context id or none.", exception.Message);
}
}
diff --git a/src/Components/Components/test/RouteViewTest.cs b/src/Components/Components/test/RouteViewTest.cs
index fbec4e51b979..c791d7243363 100644
--- a/src/Components/Components/test/RouteViewTest.cs
+++ b/src/Components/Components/test/RouteViewTest.cs
@@ -248,6 +248,11 @@ public bool CanBind(string formName, Type valueType)
return false;
}
+ public bool CanConvertSingleValue(Type type)
+ {
+ return false;
+ }
+
public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue)
{
boundValue = null;
diff --git a/src/Components/Endpoints/src/Binding/Converters/NullableConverter.cs b/src/Components/Endpoints/src/Binding/Converters/NullableConverter.cs
new file mode 100644
index 000000000000..5e3b2722d925
--- /dev/null
+++ b/src/Components/Endpoints/src/Binding/Converters/NullableConverter.cs
@@ -0,0 +1,28 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Components.Endpoints.Binding;
+
+internal sealed class NullableConverter : FormDataConverter where T : struct
+{
+ private readonly FormDataConverter _nonNullableConverter;
+
+ public NullableConverter(FormDataConverter nonNullableConverter)
+ {
+ _nonNullableConverter = nonNullableConverter;
+ }
+
+ internal override bool TryRead(ref FormDataReader context, Type type, FormDataMapperOptions options, out T? result, out bool found)
+ {
+ if (!(_nonNullableConverter.TryRead(ref context, type, options, out var innerResult, out found) && found))
+ {
+ result = null;
+ return false;
+ }
+ else
+ {
+ result = innerResult;
+ return true;
+ }
+ }
+}
diff --git a/src/Components/Endpoints/src/Binding/Converters/ParsableConverter.cs b/src/Components/Endpoints/src/Binding/Converters/ParsableConverter.cs
new file mode 100644
index 000000000000..1542f6b0fdb7
--- /dev/null
+++ b/src/Components/Endpoints/src/Binding/Converters/ParsableConverter.cs
@@ -0,0 +1,21 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Components.Endpoints.Binding;
+
+internal sealed class ParsableConverter : FormDataConverter, ISingleValueConverter where T : IParsable
+{
+ internal override bool TryRead(ref FormDataReader reader, Type type, FormDataMapperOptions options, out T? result, out bool found)
+ {
+ found = reader.TryGetValue(out var value);
+ if (found && T.TryParse(value, reader.Culture, out result))
+ {
+ return true;
+ }
+ else
+ {
+ result = default;
+ return false;
+ }
+ }
+}
diff --git a/src/Components/Endpoints/src/Binding/DefaultFormValuesSupplier.cs b/src/Components/Endpoints/src/Binding/DefaultFormValuesSupplier.cs
index 187a0578680c..8f90abaef768 100644
--- a/src/Components/Endpoints/src/Binding/DefaultFormValuesSupplier.cs
+++ b/src/Components/Endpoints/src/Binding/DefaultFormValuesSupplier.cs
@@ -1,52 +1,82 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.Reflection;
using Microsoft.AspNetCore.Components.Binding;
+using Microsoft.AspNetCore.Components.Endpoints.Binding;
using Microsoft.AspNetCore.Components.Forms;
+using Microsoft.Extensions.Primitives;
namespace Microsoft.AspNetCore.Components.Endpoints;
-internal class DefaultFormValuesSupplier : IFormValueSupplier
+internal sealed class DefaultFormValuesSupplier : IFormValueSupplier
{
- private readonly FormDataProvider _formData;
+ private static readonly MethodInfo _method = typeof(DefaultFormValuesSupplier)
+ .GetMethod(
+ nameof(DeserializeCore),
+ BindingFlags.NonPublic | BindingFlags.Static) ??
+ throw new InvalidOperationException($"Unable to find method '{nameof(DeserializeCore)}'.");
+
+ private readonly HttpContextFormDataProvider _formData;
+ private readonly FormDataMapperOptions _options = new();
+ private static readonly ConcurrentDictionary, FormDataMapperOptions, string, object>> _cache =
+ new();
public DefaultFormValuesSupplier(FormDataProvider formData)
{
- _formData = formData;
+ _formData = (HttpContextFormDataProvider)formData;
}
public bool CanBind(string formName, Type valueType)
{
return _formData.IsFormDataAvailable &&
string.Equals(formName, _formData.Name, StringComparison.Ordinal) &&
- valueType == typeof(string);
+ _options.ResolveConverter(valueType) != null;
}
public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object? boundValue)
{
- // This will delegate to a proper binder
+ // This will func to a proper binder
if (!CanBind(formName, valueType))
{
boundValue = null;
return false;
}
- if (!_formData.Entries.TryGetValue("value", out var rawValue) || rawValue.Count != 1)
- {
- boundValue = null;
- return false;
- }
-
- var valueAsString = rawValue.ToString();
+ var deserializer = _cache.GetOrAdd(valueType, CreateDeserializer);
- if (valueType == typeof(string))
+ var result = deserializer(_formData.Entries, _options, "value");
+ if (result != default)
{
- boundValue = valueAsString;
+ // This is not correct, but works for primtive values.
+ // Will change the interface when we add support for complex types.
+ boundValue = result;
return true;
}
- boundValue = null;
+ boundValue = valueType.IsValueType ? Activator.CreateInstance(valueType) : null;
return false;
}
+
+ private Func, FormDataMapperOptions, string, object> CreateDeserializer(Type type) =>
+ _method.MakeGenericMethod(type)
+ .CreateDelegate, FormDataMapperOptions, string, object>>();
+
+ private static object? DeserializeCore(IReadOnlyDictionary form, FormDataMapperOptions options, string value)
+ {
+ // Form values are parsed according to the culture of the request, which is set to the current culture by the localization middleware.
+ // Some form input types use the invariant culture when sending the data to the server. For those cases, we'll
+ // provide a way to override the culture to use to parse that value.
+ var reader = new FormDataReader(form, CultureInfo.CurrentCulture);
+ reader.PushPrefix(value);
+ return FormDataMapper.Map(reader, options);
+ }
+
+ public bool CanConvertSingleValue(Type type)
+ {
+ return _options.IsSingleValueConverter(type);
+ }
}
diff --git a/src/Components/Endpoints/src/Binding/Factories/NullableConverterFactory.cs b/src/Components/Endpoints/src/Binding/Factories/NullableConverterFactory.cs
new file mode 100644
index 000000000000..ea635aab6cb4
--- /dev/null
+++ b/src/Components/Endpoints/src/Binding/Factories/NullableConverterFactory.cs
@@ -0,0 +1,32 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+
+namespace Microsoft.AspNetCore.Components.Endpoints.Binding;
+
+internal sealed class NullableConverterFactory : IFormDataConverterFactory
+{
+ public static readonly NullableConverterFactory Instance = new();
+
+ public bool CanConvert(Type type, FormDataMapperOptions options)
+ {
+ var underlyingType = Nullable.GetUnderlyingType(type);
+ return underlyingType != null && options.ResolveConverter(underlyingType) != null;
+ }
+
+ public FormDataConverter CreateConverter(Type type, FormDataMapperOptions options)
+ {
+ var underlyingType = Nullable.GetUnderlyingType(type);
+ Debug.Assert(underlyingType != null);
+
+ var underlyingConverter = options.ResolveConverter(underlyingType);
+ Debug.Assert(underlyingConverter != null);
+
+ var expectedConverterType = typeof(NullableConverter<>).MakeGenericType(underlyingType);
+ Debug.Assert(expectedConverterType != null);
+
+ return Activator.CreateInstance(expectedConverterType, underlyingConverter) as FormDataConverter ??
+ throw new InvalidOperationException($"Unable to create converter for type '{type}'.");
+ }
+}
diff --git a/src/Components/Endpoints/src/Binding/Factories/ParsableConverterFactory.cs b/src/Components/Endpoints/src/Binding/Factories/ParsableConverterFactory.cs
new file mode 100644
index 000000000000..73cc5fccf1b7
--- /dev/null
+++ b/src/Components/Endpoints/src/Binding/Factories/ParsableConverterFactory.cs
@@ -0,0 +1,22 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Internal;
+
+namespace Microsoft.AspNetCore.Components.Endpoints.Binding;
+
+internal sealed class ParsableConverterFactory : IFormDataConverterFactory
+{
+ public static readonly ParsableConverterFactory Instance = new();
+
+ public bool CanConvert(Type type, FormDataMapperOptions options)
+ {
+ return ClosedGenericMatcher.ExtractGenericInterface(type, typeof(IParsable<>)) is not null;
+ }
+
+ public FormDataConverter CreateConverter(Type type, FormDataMapperOptions options)
+ {
+ return Activator.CreateInstance(typeof(ParsableConverter<>).MakeGenericType(type)) as FormDataConverter ??
+ throw new InvalidOperationException($"Unable to create converter for '{type.FullName}'.");
+ }
+}
diff --git a/src/Components/Endpoints/src/Binding/FormDataConverter.cs b/src/Components/Endpoints/src/Binding/FormDataConverter.cs
new file mode 100644
index 000000000000..9ff55226c19d
--- /dev/null
+++ b/src/Components/Endpoints/src/Binding/FormDataConverter.cs
@@ -0,0 +1,9 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Components.Endpoints.Binding;
+
+// Base type for all types that can map from form data to a .NET type.
+internal class FormDataConverter
+{
+}
diff --git a/src/Components/Endpoints/src/Binding/FormDataConverterOfT.cs b/src/Components/Endpoints/src/Binding/FormDataConverterOfT.cs
new file mode 100644
index 000000000000..4d1431bb5455
--- /dev/null
+++ b/src/Components/Endpoints/src/Binding/FormDataConverterOfT.cs
@@ -0,0 +1,9 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Components.Endpoints.Binding;
+
+internal abstract class FormDataConverter : FormDataConverter
+{
+ internal abstract bool TryRead(ref FormDataReader context, Type type, FormDataMapperOptions options, out T? result, out bool found);
+}
diff --git a/src/Components/Endpoints/src/Binding/FormDataMapper.cs b/src/Components/Endpoints/src/Binding/FormDataMapper.cs
new file mode 100644
index 000000000000..757aa95303c5
--- /dev/null
+++ b/src/Components/Endpoints/src/Binding/FormDataMapper.cs
@@ -0,0 +1,28 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Components.Endpoints.Binding;
+
+internal static class FormDataMapper
+{
+ public static T? Map(
+ FormDataReader reader,
+ FormDataMapperOptions options)
+ {
+ try
+ {
+ var converter = options.ResolveConverter();
+ if (converter.TryRead(ref reader, typeof(T), options, out var result, out _))
+ {
+ return result;
+ }
+
+ // We don't do error handling yet.
+
+ return default;
+ }
+ finally
+ {
+ }
+ }
+}
diff --git a/src/Components/Endpoints/src/Binding/FormDataMapperOptions.cs b/src/Components/Endpoints/src/Binding/FormDataMapperOptions.cs
new file mode 100644
index 000000000000..92e87d8fe58a
--- /dev/null
+++ b/src/Components/Endpoints/src/Binding/FormDataMapperOptions.cs
@@ -0,0 +1,53 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Concurrent;
+
+namespace Microsoft.AspNetCore.Components.Endpoints.Binding;
+
+internal sealed class FormDataMapperOptions
+{
+ private readonly ConcurrentDictionary _converters = new();
+ private readonly List> _factories = new();
+
+ public FormDataMapperOptions()
+ {
+ _converters = new(WellKnownConverters.Converters);
+
+ _factories.Add((type, options) => ParsableConverterFactory.Instance.CanConvert(type, options) ? ParsableConverterFactory.Instance.CreateConverter(type, options) : null);
+ _factories.Add((type, options) => NullableConverterFactory.Instance.CanConvert(type, options) ? NullableConverterFactory.Instance.CreateConverter(type, options) : null);
+ }
+
+ internal bool HasConverter(Type valueType) => _converters.ContainsKey(valueType);
+
+ internal bool IsSingleValueConverter(Type type)
+ {
+ return _converters.TryGetValue(type, out var converter) &&
+ converter is ISingleValueConverter;
+ }
+
+ internal FormDataConverter ResolveConverter()
+ {
+ return (FormDataConverter)_converters.GetOrAdd(typeof(T), CreateConverter, this);
+ }
+
+ private static FormDataConverter CreateConverter(Type type, FormDataMapperOptions options)
+ {
+ FormDataConverter? converter;
+ foreach (var factory in options._factories)
+ {
+ converter = factory(type, options);
+ if (converter != null)
+ {
+ return converter;
+ }
+ }
+
+ throw new InvalidOperationException($"No converter registered for type '{type.FullName}'.");
+ }
+
+ internal FormDataConverter ResolveConverter(Type type)
+ {
+ return _converters.GetOrAdd(type, CreateConverter, this);
+ }
+}
diff --git a/src/Components/Endpoints/src/Binding/FormDataReader.cs b/src/Components/Endpoints/src/Binding/FormDataReader.cs
new file mode 100644
index 000000000000..4f4afef361dc
--- /dev/null
+++ b/src/Components/Endpoints/src/Binding/FormDataReader.cs
@@ -0,0 +1,42 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using Microsoft.Extensions.Primitives;
+
+namespace Microsoft.AspNetCore.Components.Endpoints.Binding;
+
+internal struct FormDataReader
+{
+ private readonly IReadOnlyDictionary _formCollection;
+ private string _prefix;
+
+ public FormDataReader(IReadOnlyDictionary formCollection, CultureInfo culture)
+ {
+ _formCollection = formCollection;
+ _prefix = "";
+ Culture = culture;
+ }
+
+ public IFormatProvider Culture { get; internal set; }
+
+ internal void PushPrefix(string prefix)
+ {
+ _prefix = prefix;
+ }
+
+ internal readonly bool TryGetValue([NotNullWhen(true)] out string? value)
+ {
+ if (_formCollection.TryGetValue(_prefix, out var rawValue) && rawValue.Count == 1)
+ {
+ value = rawValue[0]!;
+ return true;
+ }
+ else
+ {
+ value = null;
+ return false;
+ }
+ }
+}
diff --git a/src/Components/Endpoints/src/Binding/IFormDataConverterFactory.cs b/src/Components/Endpoints/src/Binding/IFormDataConverterFactory.cs
new file mode 100644
index 000000000000..04a77cf83c49
--- /dev/null
+++ b/src/Components/Endpoints/src/Binding/IFormDataConverterFactory.cs
@@ -0,0 +1,11 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Components.Endpoints.Binding;
+
+internal interface IFormDataConverterFactory
+{
+ public bool CanConvert(Type type, FormDataMapperOptions options);
+
+ public FormDataConverter CreateConverter(Type type, FormDataMapperOptions options);
+}
diff --git a/src/Components/Endpoints/src/Binding/ISingleValueConverter.cs b/src/Components/Endpoints/src/Binding/ISingleValueConverter.cs
new file mode 100644
index 000000000000..dd1a8467c4a3
--- /dev/null
+++ b/src/Components/Endpoints/src/Binding/ISingleValueConverter.cs
@@ -0,0 +1,8 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Components.Endpoints.Binding;
+
+internal interface ISingleValueConverter
+{
+}
diff --git a/src/Components/Endpoints/src/Binding/WellKnownConverters.cs b/src/Components/Endpoints/src/Binding/WellKnownConverters.cs
new file mode 100644
index 000000000000..93031c02d958
--- /dev/null
+++ b/src/Components/Endpoints/src/Binding/WellKnownConverters.cs
@@ -0,0 +1,68 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Components.Endpoints.Binding;
+
+internal static class WellKnownConverters
+{
+ public static readonly IReadOnlyDictionary Converters;
+
+#pragma warning disable CA1810 // Initialize reference type static fields inline
+ static WellKnownConverters()
+#pragma warning restore CA1810 // Initialize reference type static fields inline
+ {
+ var converters = new Dictionary
+ {
+ // For the most common types, we avoid going through the factories and just
+ // create the converters directly. This is a performance optimization.
+ { typeof(string), new ParsableConverter() },
+ { typeof(char), new ParsableConverter() },
+ { typeof(bool), new ParsableConverter() },
+ { typeof(byte), new ParsableConverter() },
+ { typeof(sbyte), new ParsableConverter() },
+ { typeof(ushort), new ParsableConverter() },
+ { typeof(uint), new ParsableConverter() },
+ { typeof(ulong), new ParsableConverter() },
+ { typeof(Int128), new ParsableConverter() },
+ { typeof(short), new ParsableConverter() },
+ { typeof(int), new ParsableConverter() },
+ { typeof(long), new ParsableConverter() },
+ { typeof(UInt128), new ParsableConverter() },
+ { typeof(Half), new ParsableConverter() },
+ { typeof(float), new ParsableConverter() },
+ { typeof(double), new ParsableConverter() },
+ { typeof(decimal), new ParsableConverter() },
+ { typeof(DateOnly), new ParsableConverter() },
+ { typeof(DateTime), new ParsableConverter() },
+ { typeof(DateTimeOffset), new ParsableConverter() },
+ { typeof(TimeSpan), new ParsableConverter() },
+ { typeof(TimeOnly), new ParsableConverter() },
+ { typeof(Guid), new ParsableConverter() }
+ };
+
+ converters.Add(typeof(char?), new NullableConverter((FormDataConverter)converters[typeof(char)]));
+ converters.Add(typeof(bool?), new NullableConverter((FormDataConverter)converters[typeof(bool)]));
+ converters.Add(typeof(byte?), new NullableConverter((FormDataConverter)converters[typeof(byte)]));
+ converters.Add(typeof(sbyte?), new NullableConverter((FormDataConverter)converters[typeof(sbyte)]));
+ converters.Add(typeof(ushort?), new NullableConverter((FormDataConverter)converters[typeof(ushort)]));
+ converters.Add(typeof(uint?), new NullableConverter((FormDataConverter)converters[typeof(uint)]));
+ converters.Add(typeof(ulong?), new NullableConverter((FormDataConverter)converters[typeof(ulong)]));
+ converters.Add(typeof(Int128?), new NullableConverter((FormDataConverter)converters[typeof(Int128)]));
+ converters.Add(typeof(short?), new NullableConverter((FormDataConverter)converters[typeof(short)]));
+ converters.Add(typeof(int?), new NullableConverter((FormDataConverter)converters[typeof(int)]));
+ converters.Add(typeof(long?), new NullableConverter((FormDataConverter)converters[typeof(long)]));
+ converters.Add(typeof(UInt128?), new NullableConverter((FormDataConverter)converters[typeof(UInt128)]));
+ converters.Add(typeof(Half?), new NullableConverter((FormDataConverter)converters[typeof(Half)]));
+ converters.Add(typeof(float?), new NullableConverter((FormDataConverter)converters[typeof(float)]));
+ converters.Add(typeof(double?), new NullableConverter((FormDataConverter)converters[typeof(double)]));
+ converters.Add(typeof(decimal?), new NullableConverter((FormDataConverter)converters[typeof(decimal)]));
+ converters.Add(typeof(DateOnly?), new NullableConverter((FormDataConverter)converters[typeof(DateOnly)]));
+ converters.Add(typeof(DateTime?), new NullableConverter((FormDataConverter)converters[typeof(DateTime)]));
+ converters.Add(typeof(DateTimeOffset?), new NullableConverter((FormDataConverter)converters[typeof(DateTimeOffset)]));
+ converters.Add(typeof(TimeSpan?), new NullableConverter((FormDataConverter)converters[typeof(TimeSpan)]));
+ converters.Add(typeof(TimeOnly?), new NullableConverter((FormDataConverter)converters[typeof(TimeOnly)]));
+ converters.Add(typeof(Guid?), new NullableConverter((FormDataConverter)converters[typeof(Guid)]));
+
+ Converters = converters;
+ }
+}
diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsEndpointsDetailedErrorsConfiguration.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsEndpointsDetailedErrorsConfiguration.cs
index 54ee83d68c5d..ec88eca4ed21 100644
--- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsEndpointsDetailedErrorsConfiguration.cs
+++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsEndpointsDetailedErrorsConfiguration.cs
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using Microsoft.AspNetCore.Components.Endpoints;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsEndpointsOptions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsEndpointsOptions.cs
index 161bcf3b8348..1f2bd1491888 100644
--- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsEndpointsOptions.cs
+++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsEndpointsOptions.cs
@@ -1,7 +1,7 @@
-// Licensed to the .NET Foundation under one or more agreements.
+// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
-namespace Microsoft.Extensions.DependencyInjection;
+namespace Microsoft.AspNetCore.Components.Endpoints;
internal class RazorComponentsEndpointsOptions
{
diff --git a/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj b/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj
index 27764a639bc0..a6fd0a24f8c6 100644
--- a/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj
+++ b/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj
@@ -32,18 +32,14 @@
+
-
+
diff --git a/src/Components/Endpoints/test/Binding/FormDataMapperTests.cs b/src/Components/Endpoints/test/Binding/FormDataMapperTests.cs
new file mode 100644
index 000000000000..c4a4bfa040a1
--- /dev/null
+++ b/src/Components/Endpoints/test/Binding/FormDataMapperTests.cs
@@ -0,0 +1,345 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using Microsoft.Extensions.Primitives;
+
+namespace Microsoft.AspNetCore.Components.Endpoints.Binding;
+
+public class FormDataMapperTests
+{
+ [Theory]
+ [MemberData(nameof(PrimitiveTypesData))]
+ public void CanDeserialize_PrimitiveTypes(string value, Type type, object expected)
+ {
+ // Arrange
+ var collection = new Dictionary() { ["value"] = new StringValues(value) };
+ var reader = new FormDataReader(collection, CultureInfo.InvariantCulture);
+ reader.PushPrefix("value");
+ var options = new FormDataMapperOptions();
+
+ // Act
+ var result = CallDeserialize(reader, options, type);
+
+ // Assert
+ Assert.Equal(expected, result);
+ }
+
+ [Theory]
+ [MemberData(nameof(NullableBasicTypes))]
+ public void CanDeserialize_NullablePrimitiveTypes(string value, Type type, object expected)
+ {
+ // Arrange
+ var collection = new Dictionary() { ["value"] = new StringValues(value) };
+ var reader = new FormDataReader(collection, CultureInfo.InvariantCulture);
+ reader.PushPrefix("value");
+ var options = new FormDataMapperOptions();
+
+ // Act
+ var result = CallDeserialize(reader, options, type);
+
+ // Assert
+ Assert.Equal(expected, result);
+ }
+
+ [Theory]
+ [MemberData(nameof(NullNullableBasicTypes))]
+ public void CanDeserialize_NullValues(Type type)
+ {
+ // Arrange
+ var collection = new Dictionary() { };
+ var reader = new FormDataReader(collection, CultureInfo.InvariantCulture);
+ reader.PushPrefix("value");
+ var options = new FormDataMapperOptions();
+
+ // Act
+ var result = CallDeserialize(reader, options, type);
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void CanDeserialize_CustomParsableTypes()
+ {
+ // Arrange
+ var expected = new Point { X = 1, Y = 1 };
+ var collection = new Dictionary() { ["value"] = new StringValues("(1,1)") };
+ var reader = new FormDataReader(collection, CultureInfo.InvariantCulture);
+ reader.PushPrefix("value");
+ var options = new FormDataMapperOptions();
+
+ // Act
+ var result = FormDataMapper.Map(reader, options);
+
+ // Assert
+ Assert.Equal(expected, result);
+ }
+
+#nullable enable
+ [Fact]
+ public void CanDeserialize_NullableCustomParsableTypes()
+ {
+ // Arrange
+ var expected = new ValuePoint { X = 1, Y = 1 };
+ var collection = new Dictionary() { ["value"] = new StringValues("(1,1)") };
+ var reader = new FormDataReader(collection, CultureInfo.InvariantCulture);
+ reader.PushPrefix("value");
+ var options = new FormDataMapperOptions();
+
+ // Act
+ var result = FormDataMapper.Map(reader, options);
+
+ // Assert
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void CanDeserialize_NullableCustomParsableTypes_NullValue()
+ {
+ // Arrange
+ var collection = new Dictionary() { };
+ var reader = new FormDataReader(collection, CultureInfo.InvariantCulture);
+ reader.PushPrefix("value");
+ var options = new FormDataMapperOptions();
+
+ // Act
+ var result = FormDataMapper.Map(reader, options);
+
+ // Assert
+ Assert.Null(result);
+ }
+#nullable disable
+
+ public static TheoryData NullableBasicTypes
+ {
+ get
+ {
+ var result = new TheoryData
+ {
+ // strings
+ { "C", typeof(char?), new char?('C')},
+ // bool
+ { "true", typeof(bool?), new bool?(true)},
+ // bytes
+ { "63", typeof(byte?), new byte?((byte)0b_0011_1111)},
+ { "-63", typeof(sbyte?), new sbyte?((sbyte)-0b_0011_1111)},
+ // numeric types
+ { "123", typeof(ushort?), new ushort?((ushort)123u)},
+ { "456", typeof(uint?), new uint?(456u)},
+ { "789", typeof(ulong?), new ulong?(789uL)},
+ { "-101112", typeof(Int128?), new Int128?(-(Int128)101112)},
+ { "-123", typeof(short?), new short?((short)-123)},
+ { "-456", typeof(int?), new int?(-456)},
+ { "-789", typeof(long?), new long?(-789L)},
+ { "101112", typeof(UInt128?), new UInt128?((UInt128)101112)},
+ // floating point types
+ { "12.56", typeof(Half?), new Half?((Half)12.56f)},
+ { "6.28", typeof(float?), new float?(6.28f)},
+ { "3.14", typeof(double?), new double?(3.14)},
+ { "1.23", typeof(decimal?), new decimal?(1.23m)},
+ // dates and times
+ { "04/20/2023", typeof(DateOnly?), new DateOnly?(new DateOnly(2023, 04, 20))},
+ { "4/20/2023 12:56:34", typeof(DateTime?), new DateTime?(new DateTime(2023, 04, 20, 12, 56, 34))},
+ { "4/20/2023 12:56:34 PM +02:00", typeof(DateTimeOffset?), new DateTimeOffset?(new DateTimeOffset(2023, 04, 20, 12, 56, 34, TimeSpan.FromHours(2)))},
+ { "02:01:03", typeof(TimeSpan?), new TimeSpan?(new TimeSpan(02, 01, 03))},
+ { "12:56:34", typeof(TimeOnly?), new TimeOnly?(new TimeOnly(12, 56, 34))},
+
+ // other types
+ { "a55eb3df-e984-42b5-85ca-4f68da8567d1", typeof(Guid?), new Guid?(new Guid("a55eb3df-e984-42b5-85ca-4f68da8567d1")) },
+ };
+
+ return result;
+ }
+ }
+
+ public static TheoryData NullNullableBasicTypes
+ {
+ get
+ {
+ var result = new TheoryData
+ {
+ // strings
+ { typeof(char?) },
+ // bool
+ { typeof(bool?) },
+ // bytes
+ { typeof(byte?) },
+ { typeof(sbyte?) },
+ // numeric types
+ { typeof(ushort?) },
+ { typeof(uint?) },
+ { typeof(ulong?) },
+ { typeof(Int128?) },
+ { typeof(short?) },
+ { typeof(int?) },
+ { typeof(long?) },
+ { typeof(UInt128?) },
+ // floating point types
+ { typeof(Half?) },
+ { typeof(float?) },
+ { typeof(double?) },
+ { typeof(decimal?) },
+ // dates and times
+ { typeof(DateOnly?) },
+ { typeof(DateTime?) },
+ { typeof(DateTimeOffset?) },
+ { typeof(TimeSpan?) },
+ { typeof(TimeOnly?) },
+
+ // other types
+ { typeof(Guid?) },
+ };
+
+ return result;
+ }
+ }
+
+ public static TheoryData PrimitiveTypesData
+ {
+ get
+ {
+ var result = new TheoryData
+ {
+ // strings
+ { "C", typeof(char), 'C' },
+ { "hello", typeof(string), "hello" },
+ // bool
+ { "true", typeof(bool), true },
+ // bytes
+ { "63", typeof(byte), (byte)0b_0011_1111 },
+ { "-63", typeof(sbyte), (sbyte)-0b_0011_1111 },
+ // numeric types
+ { "123", typeof(ushort), (ushort)123u },
+ { "456", typeof(uint), 456u },
+ { "789", typeof(ulong), 789uL },
+ { "-101112", typeof(Int128), -(Int128)101112 },
+ { "-123", typeof(short), (short)-123 },
+ { "-456", typeof(int), -456 },
+ { "-789", typeof(long), -789L },
+ { "101112", typeof(UInt128), (UInt128)101112 },
+ // floating point types
+ { "12.56", typeof(Half), (Half)12.56f },
+ { "6.28", typeof(float), 6.28f },
+ { "3.14", typeof(double), 3.14 },
+ { "1.23", typeof(decimal), 1.23m },
+ // dates and times
+ { "04/20/2023", typeof(DateOnly), new DateOnly(2023, 04, 20) },
+ { "4/20/2023 12:56:34", typeof(DateTime), new DateTime(2023, 04, 20, 12, 56, 34) },
+ { "4/20/2023 12:56:34 PM +02:00", typeof(DateTimeOffset), new DateTimeOffset(2023, 04, 20, 12, 56, 34, TimeSpan.FromHours(2)) },
+ { "02:01:03", typeof(TimeSpan), new TimeSpan(02, 01, 03) },
+ { "12:56:34", typeof(TimeOnly), new TimeOnly(12, 56, 34) },
+
+ // other types
+ { "a55eb3df-e984-42b5-85ca-4f68da8567d1", typeof(Guid), new Guid("a55eb3df-e984-42b5-85ca-4f68da8567d1") }
+ };
+
+ return result;
+ }
+ }
+
+ private object CallDeserialize(FormDataReader reader, FormDataMapperOptions options, Type type)
+ {
+ var method = typeof(FormDataMapper)
+ .GetMethod("Map", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public) ??
+ throw new InvalidOperationException("Unable to find method 'Map'.");
+
+ return method.MakeGenericMethod(type).Invoke(null, new object[] { reader, options })!;
+ }
+}
+
+internal class Point : IParsable, IEquatable
+{
+ public int X { get; set; }
+ public int Y { get; set; }
+
+ public static Point Parse(string s, IFormatProvider provider)
+ {
+ // Parses points. Points start with ( and end with ).
+ // Points define two components, X and Y, separated by a comma.
+ var components = s.Trim('(', ')').Split(',');
+ if (components.Length != 2)
+ {
+ throw new FormatException("Invalid point format.");
+ }
+ var result = new Point();
+ result.X = int.Parse(components[0], provider);
+ result.Y = int.Parse(components[1], provider);
+ return result;
+ }
+
+ public static bool TryParse([NotNullWhen(true)] string s, IFormatProvider provider, [MaybeNullWhen(false)] out Point result)
+ {
+ // Try parse points is similar to Parse, but returns a bool to indicate success.
+ // It also uses the out parameter to return the result.
+ try
+ {
+ result = Parse(s, provider);
+ return true;
+ }
+ catch (FormatException)
+ {
+ result = null;
+ return false;
+ }
+ }
+
+ public override bool Equals(object obj) => Equals(obj as Point);
+
+ public bool Equals(Point other) => other is not null && X == other.X && Y == other.Y;
+
+ public override int GetHashCode() => HashCode.Combine(X, Y);
+
+ public static bool operator ==(Point left, Point right) => EqualityComparer.Default.Equals(left, right);
+
+ public static bool operator !=(Point left, Point right) => !(left == right);
+}
+
+internal struct ValuePoint : IParsable, IEquatable
+{
+ public int X { get; set; }
+
+ public int Y { get; set; }
+
+ public static ValuePoint Parse(string s, IFormatProvider provider)
+ {
+ // Parses points. Points start with ( and end with ).
+ // Points define two components, X and Y, separated by a comma.
+ var components = s.Trim('(', ')').Split(',');
+ if (components.Length != 2)
+ {
+ throw new FormatException("Invalid point format.");
+ }
+ var result = new ValuePoint();
+ result.X = int.Parse(components[0], provider);
+ result.Y = int.Parse(components[1], provider);
+ return result;
+ }
+
+ public static bool TryParse([NotNullWhen(true)] string s, IFormatProvider provider, [MaybeNullWhen(false)] out ValuePoint result)
+ {
+ // Try parse points is similar to Parse, but returns a bool to indicate success.
+ // It also uses the out parameter to return the result.
+ try
+ {
+ result = Parse(s, provider);
+ return true;
+ }
+ catch (FormatException)
+ {
+ result = default;
+ return false;
+ }
+ }
+
+ public override bool Equals(object obj) => Equals((ValuePoint)obj);
+
+ public bool Equals(ValuePoint other) => X == other.X && Y == other.Y;
+
+ public override int GetHashCode() => HashCode.Combine(X, Y);
+
+ public static bool operator ==(ValuePoint left, ValuePoint right) => EqualityComparer.Default.Equals(left, right);
+
+ public static bool operator !=(ValuePoint left, ValuePoint right) => !(left == right);
+}
diff --git a/src/Components/Web/src/Binding/BindingEditContextExtensions.cs b/src/Components/Web/src/Binding/BindingEditContextExtensions.cs
new file mode 100644
index 000000000000..3a430bef7393
--- /dev/null
+++ b/src/Components/Web/src/Binding/BindingEditContextExtensions.cs
@@ -0,0 +1,23 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Components.Forms;
+
+namespace Microsoft.AspNetCore.Components.Binding;
+
+internal static class BindingEditContextExtensions
+{
+ private static readonly object _convertibleTypesKey = new object();
+
+ public static void SetConvertibleValues(
+ this EditContext context,
+ ModelBindingContext binding)
+ {
+ context.Properties[_convertibleTypesKey] = (Predicate)binding.CanConvert;
+ }
+
+ public static Predicate? GetConvertibleValues(this EditContext context)
+ {
+ return context.Properties.TryGetValue(_convertibleTypesKey, out var result) ? (Predicate)result : null;
+ }
+}
diff --git a/src/Components/Web/src/Forms/EditForm.cs b/src/Components/Web/src/Forms/EditForm.cs
index 0eb80df1e40d..0e4a53bfecf5 100644
--- a/src/Components/Web/src/Forms/EditForm.cs
+++ b/src/Components/Web/src/Forms/EditForm.cs
@@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
+using Microsoft.AspNetCore.Components.Binding;
using Microsoft.AspNetCore.Components.Rendering;
namespace Microsoft.AspNetCore.Components.Forms;
@@ -121,6 +122,11 @@ protected override void OnParametersSet()
{
_editContext = new EditContext(Model!);
}
+
+ if (_editContext != null && BindingContext != null)
+ {
+ _editContext.SetConvertibleValues(BindingContext);
+ }
}
///
diff --git a/src/Components/Web/src/Forms/ExpressionFormatting/ExpressionFormatter.cs b/src/Components/Web/src/Forms/ExpressionFormatting/ExpressionFormatter.cs
index ea1b8cfa7b6c..eb62618d2391 100644
--- a/src/Components/Web/src/Forms/ExpressionFormatting/ExpressionFormatter.cs
+++ b/src/Components/Web/src/Forms/ExpressionFormatting/ExpressionFormatter.cs
@@ -23,7 +23,7 @@ public static void ClearCache()
s_methodInfoDataCache.Clear();
}
- public static string FormatLambda(LambdaExpression expression)
+ public static string FormatLambda(LambdaExpression expression, Predicate? canConvertDirectly = null)
{
var builder = new ReverseStringBuilder(stackalloc char[StackAllocBufferSize]);
var node = expression.Body;
@@ -76,8 +76,8 @@ public static string FormatLambda(LambdaExpression expression)
{
// Special case primitive values that are bound directly from the form.
// By convention, the name for the field will be "value".
- if (memberExpression.Member.IsDefined(typeof(SupplyParameterFromFormAttribute), inherit: false)
- && memberExpression.Type == typeof(string))
+ if (canConvertDirectly?.Invoke(memberExpression.Type) == true &&
+ memberExpression.Member.IsDefined(typeof(SupplyParameterFromFormAttribute), inherit: false))
{
builder.InsertFront("value");
}
diff --git a/src/Components/Web/src/Forms/InputBase.cs b/src/Components/Web/src/Forms/InputBase.cs
index 6041ddee9a4c..6a2113eab05c 100644
--- a/src/Components/Web/src/Forms/InputBase.cs
+++ b/src/Components/Web/src/Forms/InputBase.cs
@@ -5,6 +5,7 @@
using System.Globalization;
using System.Linq;
using System.Linq.Expressions;
+using Microsoft.AspNetCore.Components.Binding;
namespace Microsoft.AspNetCore.Components.Forms;
@@ -205,7 +206,9 @@ protected string NameAttributeValue
{
if (_formattedValueExpression is null && ValueExpression is not null)
{
- _formattedValueExpression = ExpressionFormatter.FormatLambda(ValueExpression);
+ _formattedValueExpression = ExpressionFormatter.FormatLambda(
+ ValueExpression,
+ EditContext.GetConvertibleValues());
}
return _formattedValueExpression ?? string.Empty;
diff --git a/src/Components/Web/src/Forms/InputCheckbox.cs b/src/Components/Web/src/Forms/InputCheckbox.cs
index ad78a4ff8074..9538b031960e 100644
--- a/src/Components/Web/src/Forms/InputCheckbox.cs
+++ b/src/Components/Web/src/Forms/InputCheckbox.cs
@@ -37,9 +37,14 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
builder.AddAttributeIfNotNullOrEmpty(3, "name", NameAttributeValue);
builder.AddAttribute(4, "class", CssClass);
builder.AddAttribute(5, "checked", BindConverter.FormatValue(CurrentValue));
- builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValue = __value, CurrentValue));
+ // Include the "value" attribute so that when this is posted by a form, "true"
+ // is included in the form fields. That's how works normally.
+ // It sends the "on" value when the checkbox is checked, and nothing otherwise.
+ builder.AddAttribute(6, "value", bool.TrueString);
+
+ builder.AddAttribute(7, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValue = __value, CurrentValue));
builder.SetUpdatesAttributeName("checked");
- builder.AddElementReferenceCapture(7, __inputReference => Element = __inputReference);
+ builder.AddElementReferenceCapture(8, __inputReference => Element = __inputReference);
builder.CloseElement();
}
diff --git a/src/Components/Web/src/Microsoft.AspNetCore.Components.Web.csproj b/src/Components/Web/src/Microsoft.AspNetCore.Components.Web.csproj
index eab4198dbc34..ba1fdcd2100d 100644
--- a/src/Components/Web/src/Microsoft.AspNetCore.Components.Web.csproj
+++ b/src/Components/Web/src/Microsoft.AspNetCore.Components.Web.csproj
@@ -1,4 +1,4 @@
-
+
$(DefaultNetCoreTargetFramework)
diff --git a/src/Components/Web/test/Forms/EditFormTest.cs b/src/Components/Web/test/Forms/EditFormTest.cs
index fa460ec92061..59f449e95a21 100644
--- a/src/Components/Web/test/Forms/EditFormTest.cs
+++ b/src/Components/Web/test/Forms/EditFormTest.cs
@@ -121,7 +121,7 @@ public async Task FormElementNameAndAction_SetToComponentName_WhenCombiningWithD
{
Model = model,
FormName = "my-form",
- BindingContext = new ModelBindingContext("", "")
+ BindingContext = new ModelBindingContext("", "", t => true)
};
// Act
@@ -142,7 +142,7 @@ public async Task FormElementNameAndAction_SetToCombinedIdentifier_WhenCombining
{
Model = model,
FormName = "my-form",
- BindingContext = new ModelBindingContext("parent-context", "path?handler=parent-context")
+ BindingContext = new ModelBindingContext("parent-context", "path?handler=parent-context", t => true )
};
// Act
@@ -167,7 +167,7 @@ public async Task FormElementNameAndAction_CanBeExplicitlyOverriden()
["name"] = "my-explicit-name",
["action"] = "/somewhere/else",
},
- BindingContext = new ModelBindingContext("parent-context", "path?handler=parent-context")
+ BindingContext = new ModelBindingContext("parent-context", "path?handler=parent-context", t => true)
};
// Act
@@ -187,7 +187,7 @@ public async Task FormElementNameAndAction_NotSetOnDefaultBindingContext()
var rootComponent = new TestEditFormHostComponent
{
Model = model,
- BindingContext = new ModelBindingContext("", ""),
+ BindingContext = new ModelBindingContext("", "", t => true),
SubmitHandler = ctx => { }
};
@@ -250,7 +250,7 @@ public async Task EventHandlerName_SetToBindingIdOnDefaultHandler()
var rootComponent = new TestEditFormHostComponent
{
Model = model,
- BindingContext = new ModelBindingContext("", "")
+ BindingContext = new ModelBindingContext("", "", t => true)
};
// Act
@@ -290,7 +290,7 @@ public async Task EventHandlerName_SetToFormNameWhenParentBindingContextIsDefaul
{
Model = model,
FormName = "my-form",
- BindingContext = new ModelBindingContext("", "")
+ BindingContext = new ModelBindingContext("", "", t => true)
};
// Act
@@ -310,7 +310,7 @@ public async Task EventHandlerName_SetToCombinedNameWhenParentBindingContextIsNa
{
Model = model,
FormName = "my-form",
- BindingContext = new ModelBindingContext("parent-context", "path?handler=parent-context")
+ BindingContext = new ModelBindingContext("parent-context", "path?handler=parent-context", t => true)
};
// Act
@@ -451,6 +451,11 @@ public bool CanBind(string formName, Type valueType)
return false;
}
+ public bool CanConvertSingleValue(Type type)
+ {
+ return false;
+ }
+
public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue)
{
boundValue = null;
diff --git a/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyFormValueSupplier.cs b/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyFormValueSupplier.cs
index bb88521ed323..a982d7d401c1 100644
--- a/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyFormValueSupplier.cs
+++ b/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyFormValueSupplier.cs
@@ -12,6 +12,11 @@ public bool CanBind(string formName, Type valueType)
return false;
}
+ public bool CanConvertSingleValue(Type type)
+ {
+ return false;
+ }
+
public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object? boundValue)
{
boundValue = null;
diff --git a/src/Components/WebView/WebView/src/Services/WebViewFormValueSupplier.cs b/src/Components/WebView/WebView/src/Services/WebViewFormValueSupplier.cs
index 39af8f25e3f5..7405b541cfe7 100644
--- a/src/Components/WebView/WebView/src/Services/WebViewFormValueSupplier.cs
+++ b/src/Components/WebView/WebView/src/Services/WebViewFormValueSupplier.cs
@@ -13,6 +13,11 @@ public bool CanBind(string formName, Type valueType)
return false;
}
+ public bool CanConvertSingleValue(Type type)
+ {
+ return false;
+ }
+
public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object? boundValue)
{
boundValue = null;
diff --git a/src/Mvc/test/Mvc.FunctionalTests/ComponentRenderingFunctionalTests.cs b/src/Mvc/test/Mvc.FunctionalTests/ComponentRenderingFunctionalTests.cs
index ecc52726c805..1b0f3cb2dd83 100644
--- a/src/Mvc/test/Mvc.FunctionalTests/ComponentRenderingFunctionalTests.cs
+++ b/src/Mvc/test/Mvc.FunctionalTests/ComponentRenderingFunctionalTests.cs
@@ -39,7 +39,8 @@ public async Task Renders_BasicComponent()
public async Task Renders_RoutingComponent()
{
// Arrange & Act
- var client = CreateClient(Factory.WithWebHostBuilder(builder => builder.ConfigureServices(services => services.AddServerSideBlazor())));
+ var client = CreateClient(Factory.WithWebHostBuilder(builder =>
+ builder.ConfigureServices(services => services.AddRazorComponents().AddServerComponents())));
var response = await client.GetAsync("http://localhost/components/routable");
@@ -69,8 +70,8 @@ public async Task Redirects_Navigation_Component()
public async Task Renders_RoutingComponent_UsingRazorComponents_Prerenderer()
{
// Arrange & Act
- var client = CreateClient(Factory
- .WithWebHostBuilder(builder => builder.ConfigureServices(services => services.AddServerSideBlazor())));
+ var client = CreateClient(Factory.WithWebHostBuilder(builder =>
+ builder.ConfigureServices(services => services.AddRazorComponents().AddServerComponents())));
var response = await client.GetAsync("http://localhost/components/routable");
@@ -85,7 +86,8 @@ public async Task Renders_RoutingComponent_UsingRazorComponents_Prerenderer()
public async Task Renders_ThrowingComponent_UsingRazorComponents_Prerenderer()
{
// Arrange & Act
- var client = CreateClient(Factory.WithWebHostBuilder(builder => builder.ConfigureServices(services => services.AddServerSideBlazor())));
+ var client = CreateClient(Factory.WithWebHostBuilder(builder =>
+ builder.ConfigureServices(services => services.AddRazorComponents().AddServerComponents())));
var response = await client.GetAsync("http://localhost/components/throws");
diff --git a/src/Shared/ClosedGenericMatcher/ClosedGenericMatcher.cs b/src/Shared/ClosedGenericMatcher/ClosedGenericMatcher.cs
index 7a77dfa3d4cc..c1989174bc1e 100644
--- a/src/Shared/ClosedGenericMatcher/ClosedGenericMatcher.cs
+++ b/src/Shared/ClosedGenericMatcher/ClosedGenericMatcher.cs
@@ -32,8 +32,13 @@ internal static class ClosedGenericMatcher
///
public static Type? ExtractGenericInterface(Type queryType, Type interfaceType)
{
+#if !NET8_0_OR_GREATER
ArgumentNullThrowHelper.ThrowIfNull(queryType);
ArgumentNullThrowHelper.ThrowIfNull(interfaceType);
+#else
+ ArgumentNullException.ThrowIfNull(queryType);
+ ArgumentNullException.ThrowIfNull(interfaceType);
+#endif
if (IsGenericInstantiation(queryType, interfaceType))
{