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