diff --git a/src/Components/Authorization/test/AuthorizeRouteViewTest.cs b/src/Components/Authorization/test/AuthorizeRouteViewTest.cs index e0db0c46914d..c099b0c91063 100644 --- a/src/Components/Authorization/test/AuthorizeRouteViewTest.cs +++ b/src/Components/Authorization/test/AuthorizeRouteViewTest.cs @@ -1,8 +1,10 @@ // 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.Security.Claims; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components.Binding; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.Test.Helpers; @@ -32,6 +34,7 @@ public AuthorizeRouteViewTest() serviceCollection.AddSingleton(); serviceCollection.AddSingleton(_testAuthorizationService); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); var services = serviceCollection.BuildServiceProvider(); _renderer = new TestRenderer(services); @@ -467,4 +470,18 @@ public TestNavigationManager() Initialize("https://localhost:85/subdir/", "https://localhost:85/subdir/path?query=value#hash"); } } + + private class TestFormValueSupplier : IFormValueSupplier + { + public bool CanBind(string formName, Type valueType) + { + return false; + } + + public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue) + { + boundValue = null; + return false; + } + } } diff --git a/src/Components/Components/src/Binding/CascadingModelBinder.cs b/src/Components/Components/src/Binding/CascadingModelBinder.cs index 4a6b842cc4f9..cdf6d4d2e755 100644 --- a/src/Components/Components/src/Binding/CascadingModelBinder.cs +++ b/src/Components/Components/src/Binding/CascadingModelBinder.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Reflection.Metadata; +using Microsoft.AspNetCore.Components.Binding; +using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Routing; namespace Microsoft.AspNetCore.Components; @@ -9,11 +11,12 @@ namespace Microsoft.AspNetCore.Components; /// /// Defines the binding context for data bound from external sources. /// -public sealed class CascadingModelBinder : IComponent, IDisposable +public sealed class CascadingModelBinder : IComponent, ICascadingValueComponent, IDisposable { private RenderHandle _handle; private ModelBindingContext? _bindingContext; private bool _hasPendingQueuedRender; + private BindingInfo? _bindingInfo; /// /// The binding context name. @@ -35,7 +38,9 @@ public sealed class CascadingModelBinder : IComponent, IDisposable [CascadingParameter] ModelBindingContext? ParentContext { get; set; } - [Inject] private NavigationManager Navigation { get; set; } = null!; + [Inject] internal NavigationManager Navigation { get; set; } = null!; + + [Inject] internal IFormValueSupplier FormValueSupplier { get; set; } = null!; void IComponent.Attach(RenderHandle renderHandle) { @@ -87,7 +92,7 @@ private void HandleLocationChanged(object? sender, LocationChangedEventArgs e) Render(); } - private void UpdateBindingInformation(string url) + internal void UpdateBindingInformation(string url) { // BindingContextId: action parameter used to define the handler // Name: form name and context used to bind @@ -103,11 +108,11 @@ private void UpdateBindingInformation(string url) // 3) Parent has a name "parent-name" // Name = "parent-name.my-handler"; // BindingContextId = <>((<>&)|?)handler=my-handler - var name = string.IsNullOrEmpty(ParentContext?.Name) ? Name : $"{ParentContext.Name}.{Name}"; + var name = ModelBindingContext.Combine(ParentContext, Name); var bindingId = string.IsNullOrEmpty(name) ? "" : GenerateBindingContextId(name); var bindingContext = _bindingContext != null && - string.Equals(_bindingContext.Name, Name, StringComparison.Ordinal) && + string.Equals(_bindingContext.Name, name, StringComparison.Ordinal) && string.Equals(_bindingContext.BindingContextId, bindingId, StringComparison.Ordinal) ? _bindingContext : new ModelBindingContext(name, bindingId); @@ -136,4 +141,54 @@ void IDisposable.Dispose() { Navigation.LocationChanged -= HandleLocationChanged; } + + bool ICascadingValueComponent.CanSupplyValue(Type valueType, string? valueName) + { + var formName = string.IsNullOrEmpty(valueName) ? + (_bindingContext?.Name) : + ModelBindingContext.Combine(_bindingContext, valueName); + + if (_bindingInfo != null && + string.Equals(_bindingInfo.FormName, formName, StringComparison.Ordinal) && + _bindingInfo.ValueType.Equals(valueType)) + { + // We already bound the value, but some component might have been destroyed and + // re-created. If the type and name of the value that we bound are the same, + // we can provide the value that we bound. + return true; + } + + // Can't supply the value if this context is for a form with a different name. + if (FormValueSupplier.CanBind(formName!, valueType)) + { + var bindingSucceeded = FormValueSupplier.TryBind(formName!, valueType, out var boundValue); + _bindingInfo = new BindingInfo(formName, valueType, bindingSucceeded, boundValue); + if (!bindingSucceeded) + { + // Report errors + } + + return true; + } + + return false; + } + + void ICascadingValueComponent.Subscribe(ComponentState subscriber) + { + throw new InvalidOperationException("Form values are always fixed."); + } + + void ICascadingValueComponent.Unsubscribe(ComponentState subscriber) + { + throw new InvalidOperationException("Form values are always fixed."); + } + + object? ICascadingValueComponent.CurrentValue => _bindingInfo == null ? + throw new InvalidOperationException("Tried to access form value before it was bound.") : + _bindingInfo.BoundValue; + + bool ICascadingValueComponent.CurrentValueIsFixed => true; + + private record BindingInfo(string? FormName, Type ValueType, bool BindingResult, object? BoundValue); } diff --git a/src/Components/Components/src/Binding/IFormValueSupplier.cs b/src/Components/Components/src/Binding/IFormValueSupplier.cs new file mode 100644 index 000000000000..364f4041b362 --- /dev/null +++ b/src/Components/Components/src/Binding/IFormValueSupplier.cs @@ -0,0 +1,29 @@ +// 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; + +namespace Microsoft.AspNetCore.Components.Binding; + +/// +/// Binds form data valuesto a model. +/// +public interface IFormValueSupplier +{ + /// + /// Determines whether the specified value type can be bound. + /// + /// The form name to bind data from. + /// The for the value to bind. + /// true if the value type can be bound; otherwise, false. + bool CanBind(string formName, Type valueType); + + /// + /// Tries to bind the form with the specified name to a value of the specified type. + /// + /// The form name to bind data from. + /// The for the value to bind. + /// The bound value if succeeded. + /// true if the form was bound successfully; otherwise, false. + bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object? boundValue); +} diff --git a/src/Components/Components/src/Binding/ModelBindingContext.cs b/src/Components/Components/src/Binding/ModelBindingContext.cs index 0659b325d8e1..653ffdcf068d 100644 --- a/src/Components/Components/src/Binding/ModelBindingContext.cs +++ b/src/Components/Components/src/Binding/ModelBindingContext.cs @@ -34,4 +34,7 @@ internal ModelBindingContext(string name, string bindingContextId) /// The computed identifier used to determine what parts of the app can bind data. /// public string BindingContextId { get; } + + internal static string Combine(ModelBindingContext? parentContext, string name) => + string.IsNullOrEmpty(parentContext?.Name) ? name : $"{parentContext.Name}.{name}"; } diff --git a/src/Components/Components/src/CascadingParameterState.cs b/src/Components/Components/src/CascadingParameterState.cs index 52caad659d0b..7af4dc1b9cce 100644 --- a/src/Components/Components/src/CascadingParameterState.cs +++ b/src/Components/Components/src/CascadingParameterState.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Reflection; using Microsoft.AspNetCore.Components.Reflection; using Microsoft.AspNetCore.Components.Rendering; @@ -45,11 +46,8 @@ public static IReadOnlyList FindCascadingParameters(Com var supplier = GetMatchingCascadingValueSupplier(info, componentState); if (supplier != null) { - if (resultStates == null) - { - // Although not all parameters might be matched, we know the maximum number - resultStates = new List(infos.Length - infoIndex); - } + // Although not all parameters might be matched, we know the maximum number + resultStates ??= new List(infos.Length - infoIndex); resultStates.Add(new CascadingParameterState(info.ConsumerValueName, supplier)); } @@ -98,16 +96,25 @@ private static ReflectedCascadingParameterInfo[] CreateReflectedCascadingParamet var attribute = prop.GetCustomAttribute(); if (attribute != null) { - if (result == null) - { - result = new List(); - } + result ??= new List(); result.Add(new ReflectedCascadingParameterInfo( prop.Name, prop.PropertyType, attribute.Name)); } + + var hostParameterAttribute = prop.GetCustomAttributes() + .OfType().SingleOrDefault(); + if (hostParameterAttribute != null) + { + result ??= new List(); + + result.Add(new ReflectedCascadingParameterInfo( + prop.Name, + prop.PropertyType, + hostParameterAttribute.Name)); + } } return result?.ToArray() ?? Array.Empty(); diff --git a/src/Components/Components/src/IHostEnvironmentCascadingParameter.cs b/src/Components/Components/src/IHostEnvironmentCascadingParameter.cs new file mode 100644 index 000000000000..8f407e0cdd5e --- /dev/null +++ b/src/Components/Components/src/IHostEnvironmentCascadingParameter.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; + +// Marks a cascading parameter that can be offered via an attribute that is not +// directly defined in the Components assembly. For example [SupplyParameterFromForm]. +internal interface IHostEnvironmentCascadingParameter +{ + public string? Name { get; set; } +} diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index a03797860d22..7fe8a6a3519c 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1,5 +1,8 @@ #nullable enable 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.TryBind(string! formName, System.Type! valueType, out object? boundValue) -> bool Microsoft.AspNetCore.Components.CascadingModelBinder Microsoft.AspNetCore.Components.CascadingModelBinder.CascadingModelBinder() -> void Microsoft.AspNetCore.Components.CascadingModelBinder.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment! diff --git a/src/Components/Components/src/Reflection/ComponentProperties.cs b/src/Components/Components/src/Reflection/ComponentProperties.cs index dd0df1e094cd..9569e8e6a84e 100644 --- a/src/Components/Components/src/Reflection/ComponentProperties.cs +++ b/src/Components/Components/src/Reflection/ComponentProperties.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Reflection; using static Microsoft.AspNetCore.Internal.LinkerFlags; @@ -168,11 +169,14 @@ private static void ThrowForUnknownIncomingParameterName([DynamicallyAccessedMem var propertyInfo = targetType.GetProperty(parameterName, BindablePropertyFlags); if (propertyInfo != null) { - if (!propertyInfo.IsDefined(typeof(ParameterAttribute)) && !propertyInfo.IsDefined(typeof(CascadingParameterAttribute))) + if (!propertyInfo.IsDefined(typeof(ParameterAttribute)) && + !propertyInfo.IsDefined(typeof(CascadingParameterAttribute)) && + !propertyInfo.GetCustomAttributes().OfType().Any()) { throw new InvalidOperationException( $"Object of type '{targetType.FullName}' has a property matching the name '{parameterName}', " + - $"but it does not have [{nameof(ParameterAttribute)}] or [{nameof(CascadingParameterAttribute)}] applied."); + $"but it does not have [{nameof(ParameterAttribute)}], [{nameof(CascadingParameterAttribute)}] or " + + $"[SupplyParameterFromFormAttribute] applied."); } else { @@ -257,9 +261,30 @@ public WritersForType([DynamicallyAccessedMembers(Component)] Type targetType) foreach (var propertyInfo in GetCandidateBindableProperties(targetType)) { - var parameterAttribute = propertyInfo.GetCustomAttribute(); - var cascadingParameterAttribute = propertyInfo.GetCustomAttribute(); - var isParameter = parameterAttribute != null || cascadingParameterAttribute != null; + ParameterAttribute? parameterAttribute = null; + CascadingParameterAttribute? cascadingParameterAttribute = null; + IHostEnvironmentCascadingParameter? hostEnvironmentCascadingParameter = null; + + var attributes = propertyInfo.GetCustomAttributes(); + foreach (var attribute in attributes) + { + switch (attribute) + { + case ParameterAttribute parameter: + parameterAttribute = parameter; + break; + case CascadingParameterAttribute cascadingParameter: + cascadingParameterAttribute = cascadingParameter; + break; + case IHostEnvironmentCascadingParameter hostEnvironmentAttribute: + hostEnvironmentCascadingParameter = hostEnvironmentAttribute; + break; + default: + break; + } + } + + var isParameter = parameterAttribute != null || cascadingParameterAttribute != null || hostEnvironmentCascadingParameter != null; if (!isParameter) { continue; @@ -274,7 +299,7 @@ public WritersForType([DynamicallyAccessedMembers(Component)] Type targetType) var propertySetter = new PropertySetter(targetType, propertyInfo) { - Cascading = cascadingParameterAttribute != null, + Cascading = cascadingParameterAttribute != null || hostEnvironmentCascadingParameter != null, }; if (_underlyingWriters.ContainsKey(propertyName)) diff --git a/src/Components/Components/test/CascadingModelBinderTest.cs b/src/Components/Components/test/CascadingModelBinderTest.cs index e8a60445b8c9..5bdd96417b86 100644 --- a/src/Components/Components/test/CascadingModelBinderTest.cs +++ b/src/Components/Components/test/CascadingModelBinderTest.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components.Binding; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Test.Helpers; using Microsoft.Extensions.DependencyInjection; @@ -18,6 +19,7 @@ public CascadingModelBinderTest() var serviceCollection = new ServiceCollection(); _navigationManager = new TestNavigationManager(); serviceCollection.AddSingleton(_navigationManager); + serviceCollection.AddSingleton(); var services = serviceCollection.BuildServiceProvider(); _renderer = new TestRenderer(services); } @@ -328,4 +330,18 @@ public TestComponent(RenderFragment renderFragment) protected override void BuildRenderTree(RenderTreeBuilder builder) => _renderFragment(builder); } + + private class TestFormValueSupplier : IFormValueSupplier + { + public bool CanBind(string formName, Type valueType) + { + return false; + } + + public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue) + { + boundValue = null; + return false; + } + } } diff --git a/src/Components/Components/test/CascadingParameterStateTest.cs b/src/Components/Components/test/CascadingParameterStateTest.cs index 648056c152c0..95f1201137db 100644 --- a/src/Components/Components/test/CascadingParameterStateTest.cs +++ b/src/Components/Components/test/CascadingParameterStateTest.cs @@ -1,8 +1,11 @@ // 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 Microsoft.AspNetCore.Components.Binding; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Test.Helpers; +using Moq; namespace Microsoft.AspNetCore.Components; @@ -355,6 +358,68 @@ public void FindCascadingParameters_CanOverrideNonNullValueWithNull() }); } + [Fact] + public void FindCascadingParameters_HandlesSupplyParameterFromFormValues() + { + // Arrange + var cascadingModelBinder = new CascadingModelBinder + { + FormValueSupplier = new TestFormValueSupplier() + { + FormName = "", + ValueType = typeof(string), + BindResult = true, + BoundValue = "some value" + }, + Navigation = Mock.Of(), + Name = "" + }; + + cascadingModelBinder.UpdateBindingInformation("https://localhost/"); + + var states = CreateAncestry( + cascadingModelBinder, + new FormParametersComponent()); + + // Act + var result = CascadingParameterState.FindCascadingParameters(states.Last()); + + // Assert + var supplier = Assert.Single(result); + Assert.Equal(cascadingModelBinder, supplier.ValueSupplier); + } + + [Fact] + public void FindCascadingParameters_HandlesSupplyParameterFromFormValues_WithName() + { + // Arrange + var cascadingModelBinder = new CascadingModelBinder + { + FormValueSupplier = new TestFormValueSupplier() + { + FormName = "some-name", + ValueType = typeof(string), + BindResult = true, + BoundValue = "some value" + }, + Navigation = new TestNavigationManager(), + Name = "" + }; + + cascadingModelBinder.UpdateBindingInformation("https://localhost/"); + + var states = CreateAncestry( + cascadingModelBinder, + new FormParametersComponentWithName()); + + // Act + var result = CascadingParameterState.FindCascadingParameters(states.Last()); + + // Assert + var supplier = Assert.Single(result); + Assert.Equal(cascadingModelBinder, supplier.ValueSupplier); + } + static ComponentState[] CreateAncestry(params IComponent[] components) { var result = new ComponentState[components.Length]; @@ -399,6 +464,16 @@ class ComponentWithNoParams : TestComponentBase { } + class FormParametersComponent : TestComponentBase + { + [SupplyParameterFromForm] public string FormParameter { get; set; } + } + + class FormParametersComponentWithName : TestComponentBase + { + [SupplyParameterFromForm(Name = "some-name")] public string FormParameter { get; set; } + } + class ComponentWithNoCascadingParams : TestComponentBase { [Parameter] public bool SomeRegularParameter { get; set; } @@ -443,4 +518,45 @@ class ValueType3 { } class CascadingValueTypeBaseClass { } class CascadingValueTypeDerivedClass : CascadingValueTypeBaseClass, ICascadingValueTypeDerivedClassInterface { } interface ICascadingValueTypeDerivedClassInterface { } + + private class TestFormValueSupplier : IFormValueSupplier + { + public string FormName { get; set; } + + public Type ValueType { get; set; } + + public object BoundValue { get; set; } + + public bool BindResult { get; set; } + + public bool CanBind(string formName, Type valueType) + { + return string.Equals(formName, FormName, StringComparison.Ordinal) && + valueType == ValueType; + } + + public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue) + { + boundValue = BoundValue; + return BindResult; + } + } + + class TestNavigationManager : NavigationManager + { + public TestNavigationManager() + { + Initialize("https://localhost:85/subdir/", "https://localhost:85/subdir/path?query=value#hash"); + } + } +} + +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] +public sealed class SupplyParameterFromFormAttribute : Attribute, IHostEnvironmentCascadingParameter +{ + /// + /// Gets or sets the name for the parameter. The name is used to match + /// the form data and decide whether or not the value needs to be bound. + /// + public string Name { get; set; } } diff --git a/src/Components/Components/test/ParameterViewTest.Assignment.cs b/src/Components/Components/test/ParameterViewTest.Assignment.cs index 8fff5c2003d0..bcc032c7e080 100644 --- a/src/Components/Components/test/ParameterViewTest.Assignment.cs +++ b/src/Components/Components/test/ParameterViewTest.Assignment.cs @@ -181,7 +181,7 @@ public void IncomingParameterMatchesPropertyNotDeclaredAsParameter_Throws() Assert.Equal(default, target.IntProp); Assert.Equal( $"Object of type '{typeof(HasPropertyWithoutParameterAttribute).FullName}' has a property matching the name '{nameof(HasPropertyWithoutParameterAttribute.IntProp)}', " + - $"but it does not have [{nameof(ParameterAttribute)}] or [{nameof(CascadingParameterAttribute)}] applied.", + $"but it does not have [{nameof(ParameterAttribute)}], [{nameof(CascadingParameterAttribute)}] or [{nameof(SupplyParameterFromFormAttribute)}] applied.", ex.Message); } diff --git a/src/Components/Components/test/RouteViewTest.cs b/src/Components/Components/test/RouteViewTest.cs index 92abd7e97b47..fbec4e51b979 100644 --- a/src/Components/Components/test/RouteViewTest.cs +++ b/src/Components/Components/test/RouteViewTest.cs @@ -1,6 +1,8 @@ // 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 Microsoft.AspNetCore.Components.Binding; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Test.Helpers; using Microsoft.Extensions.DependencyInjection; @@ -19,6 +21,7 @@ public RouteViewTest() var serviceCollection = new ServiceCollection(); _navigationManager = new RouteViewTestNavigationManager(); serviceCollection.AddSingleton(_navigationManager); + serviceCollection.AddSingleton(); var services = serviceCollection.BuildServiceProvider(); _renderer = new TestRenderer(services); @@ -237,4 +240,18 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddContent(2, "OtherLayout ends here"); } } + + private class TestFormValueSupplier : IFormValueSupplier + { + public bool CanBind(string formName, Type valueType) + { + return false; + } + + public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue) + { + boundValue = null; + return false; + } + } } diff --git a/src/Components/Endpoints/src/Binding/DefaultFormValuesSupplier.cs b/src/Components/Endpoints/src/Binding/DefaultFormValuesSupplier.cs new file mode 100644 index 000000000000..187a0578680c --- /dev/null +++ b/src/Components/Endpoints/src/Binding/DefaultFormValuesSupplier.cs @@ -0,0 +1,52 @@ +// 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 Microsoft.AspNetCore.Components.Binding; +using Microsoft.AspNetCore.Components.Forms; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal class DefaultFormValuesSupplier : IFormValueSupplier +{ + private readonly FormDataProvider _formData; + + public DefaultFormValuesSupplier(FormDataProvider formData) + { + _formData = formData; + } + + public bool CanBind(string formName, Type valueType) + { + return _formData.IsFormDataAvailable && + string.Equals(formName, _formData.Name, StringComparison.Ordinal) && + valueType == typeof(string); + } + + public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object? boundValue) + { + // This will delegate 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(); + + if (valueType == typeof(string)) + { + boundValue = valueAsString; + return true; + } + + boundValue = null; + return false; + } +} diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs index 8be227d6cca8..8ef454f5bbf9 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Binding; using Microsoft.AspNetCore.Components.Endpoints; using Microsoft.AspNetCore.Components.Endpoints.DependencyInjection; using Microsoft.AspNetCore.Components.Forms; @@ -56,6 +57,7 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection // Form handling services.TryAddScoped(); + services.TryAddScoped(); return new DefaultRazorComponentsBuilder(services); } diff --git a/src/Components/Samples/BlazorUnitedApp/Pages/Index.razor b/src/Components/Samples/BlazorUnitedApp/Pages/Index.razor index 1684adfe5462..bb031aa6572f 100644 --- a/src/Components/Samples/BlazorUnitedApp/Pages/Index.razor +++ b/src/Components/Samples/BlazorUnitedApp/Pages/Index.razor @@ -2,16 +2,12 @@ Index -

Hello, world!

- -@* - - - - - - *@ +

@Parameter

+ + + + @if (_submitted) { @@ -19,6 +15,8 @@ } @code{ + [SupplyParameterFromForm] string Parameter { get; set; } = "Hello, world!"; + bool _submitted = false; public void Submit() { diff --git a/src/Components/Web/src/Forms/ExpressionFormatting/ExpressionFormatter.cs b/src/Components/Web/src/Forms/ExpressionFormatting/ExpressionFormatter.cs index e3fc1b199616..ea1b8cfa7b6c 100644 --- a/src/Components/Web/src/Forms/ExpressionFormatting/ExpressionFormatter.cs +++ b/src/Components/Web/src/Forms/ExpressionFormatting/ExpressionFormatter.cs @@ -74,6 +74,13 @@ public static string FormatLambda(LambdaExpression expression) if (nextNode?.NodeType == ExpressionType.Constant) { + // 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)) + { + builder.InsertFront("value"); + } // The next node has a compiler-generated closure type, // which means the current member access is on the captured model. // We don't want to include the model variable name in the generated @@ -165,7 +172,7 @@ static MethodInfoData GetMethodInfoData(MethodInfo methodInfo) return new(IsSingleArgumentIndexer: false); } } - + private static void FormatIndexArgument( Expression indexExpression, ref ReverseStringBuilder builder) diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index dbeb2dc911e7..780363a5e104 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -17,6 +17,10 @@ Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer. Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer.BeginRenderingComponent(System.Type! componentType, Microsoft.AspNetCore.Components.ParameterView initialParameters) -> Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlRootComponent Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer.StaticHtmlRenderer(System.IServiceProvider! serviceProvider, Microsoft.Extensions.Logging.ILoggerFactory! loggerFactory) -> void Microsoft.AspNetCore.Components.RenderTree.WebRenderer.WaitUntilAttachedAsync() -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Components.SupplyParameterFromFormAttribute +Microsoft.AspNetCore.Components.SupplyParameterFromFormAttribute.Name.get -> string? +Microsoft.AspNetCore.Components.SupplyParameterFromFormAttribute.Name.set -> void +Microsoft.AspNetCore.Components.SupplyParameterFromFormAttribute.SupplyParameterFromFormAttribute() -> void Microsoft.AspNetCore.Components.Web.AutoRenderMode Microsoft.AspNetCore.Components.Web.AutoRenderMode.AutoRenderMode() -> void Microsoft.AspNetCore.Components.Web.AutoRenderMode.AutoRenderMode(bool prerender) -> void diff --git a/src/Components/Web/src/SupplyParameterFromFormAttribute.cs b/src/Components/Web/src/SupplyParameterFromFormAttribute.cs new file mode 100644 index 000000000000..2ae657c95583 --- /dev/null +++ b/src/Components/Web/src/SupplyParameterFromFormAttribute.cs @@ -0,0 +1,18 @@ +// 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; + +/// +/// Indicates that the value of the associated property should be supplied from +/// the form data for the form with the specified name. +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] +public sealed class SupplyParameterFromFormAttribute : Attribute, IHostEnvironmentCascadingParameter +{ + /// + /// Gets or sets the name for the parameter. The name is used to match + /// the form data and decide whether or not the value needs to be bound. + /// + public string? Name { get; set; } +} diff --git a/src/Components/Web/test/Forms/EditFormTest.cs b/src/Components/Web/test/Forms/EditFormTest.cs index ae08af1ccdf9..fa460ec92061 100644 --- a/src/Components/Web/test/Forms/EditFormTest.cs +++ b/src/Components/Web/test/Forms/EditFormTest.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components.Binding; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.Test.Helpers; @@ -17,6 +19,7 @@ public EditFormTest() { var services = new ServiceCollection(); services.AddSingleton(); + services.AddSingleton(); _testRenderer = new(services.BuildServiceProvider()); } @@ -440,4 +443,18 @@ public TestNavigationManager() Initialize("https://localhost:85/subdir/", "https://localhost:85/subdir/path?query=value#hash"); } } + + private class TestFormValueSupplier : IFormValueSupplier + { + public bool CanBind(string formName, Type valueType) + { + return false; + } + + public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue) + { + boundValue = null; + return false; + } + } } diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs index f5d525cbdaf0..3345d113eb4b 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs @@ -262,5 +262,6 @@ internal void InitializeDefaultServices() builder.AddProvider(new WebAssemblyConsoleLoggerProvider(DefaultWebAssemblyJSRuntime.Instance)); }); Services.AddSingleton(); + Services.AddSingleton(); } } diff --git a/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyFormValueSupplier.cs b/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyFormValueSupplier.cs new file mode 100644 index 000000000000..bb88521ed323 --- /dev/null +++ b/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyFormValueSupplier.cs @@ -0,0 +1,20 @@ +// 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 Microsoft.AspNetCore.Components.Binding; + +namespace Microsoft.AspNetCore.Components.WebAssembly.Services; +internal class WebAssemblyFormValueSupplier : IFormValueSupplier +{ + public bool CanBind(string formName, Type valueType) + { + return false; + } + + public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object? boundValue) + { + boundValue = null; + return false; + } +} diff --git a/src/Components/WebView/WebView/src/ComponentsWebViewServiceCollectionExtensions.cs b/src/Components/WebView/WebView/src/ComponentsWebViewServiceCollectionExtensions.cs index e6420673fbbf..e522547c6971 100644 --- a/src/Components/WebView/WebView/src/ComponentsWebViewServiceCollectionExtensions.cs +++ b/src/Components/WebView/WebView/src/ComponentsWebViewServiceCollectionExtensions.cs @@ -31,6 +31,8 @@ public static IServiceCollection AddBlazorWebView(this IServiceCollection servic services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); + return services; } } diff --git a/src/Components/WebView/WebView/src/Services/WebViewFormValueSupplier.cs b/src/Components/WebView/WebView/src/Services/WebViewFormValueSupplier.cs new file mode 100644 index 000000000000..39af8f25e3f5 --- /dev/null +++ b/src/Components/WebView/WebView/src/Services/WebViewFormValueSupplier.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. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components.Binding; + +namespace Microsoft.AspNetCore.Components.WebView.Services; + +internal class WebViewFormValueSupplier : IFormValueSupplier +{ + public bool CanBind(string formName, Type valueType) + { + return false; + } + + public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object? boundValue) + { + boundValue = null; + return false; + } +} diff --git a/src/Components/test/E2ETest/ServerRenderingTests/FormHandlingTests/FormWithParentBindingContextTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/FormHandlingTests/FormWithParentBindingContextTest.cs index 928774c49e10..d0799f139fd4 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/FormHandlingTests/FormWithParentBindingContextTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/FormHandlingTests/FormWithParentBindingContextTest.cs @@ -1,23 +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.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using System.Net.Http; +using Components.TestServer.RazorComponents; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; using Microsoft.AspNetCore.E2ETesting; +using OpenQA.Selenium; using TestServer; using Xunit.Abstractions; -using OpenQA.Selenium; -using System.Net.Http; -using static System.Net.Mime.MediaTypeNames; -using Components.TestServer.RazorComponents; namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests.FormHandlingTests; -public class FormWithParentBindingContextTest : ServerTestBase>> +public class FormWithParentBindingContextTest : ServerTestBase>> { public FormWithParentBindingContextTest( BrowserFixture browserFixture, - BasicTestAppServerSiteFixture> serverFixture, + BasicTestAppServerSiteFixture> serverFixture, ITestOutputHelper output) : base(browserFixture, serverFixture, output) { @@ -51,6 +50,21 @@ public void CanDispatchToTheDefaultFormWithBody() DispatchToFormCore(dispatchToForm); } + [Fact] + public void CanBindParameterToTheDefaultForm() + { + var dispatchToForm = new DispatchToForm(this) + { + Url = "forms/default-form-bound-parameter", + FormCssSelector = "form", + ExpectedActionValue = null, + InputFieldId = "value", + InputFieldCssSelector = "input[name=value]", + InputFieldValue = "stranger", + }; + DispatchToFormCore(dispatchToForm); + } + [Fact] public void CanReadFormValuesDuringOnInitialized() { @@ -76,6 +90,21 @@ public void CanDispatchToNamedForm() DispatchToFormCore(dispatchToForm); } + [Fact] + public void CanBindFormValueFromNamedFormWithBody() + { + var dispatchToForm = new DispatchToForm(this) + { + Url = "forms/named-form-bound-parameter", + FormCssSelector = "form[name=named-form-handler]", + ExpectedActionValue = "forms/named-form-bound-parameter?handler=named-form-handler", + InputFieldId = "value", + InputFieldCssSelector = "input[name=value]", + InputFieldValue = "stranger", + }; + DispatchToFormCore(dispatchToForm); + } + [Fact] public void CanDispatchToNamedFormInNestedContext() { @@ -88,6 +117,21 @@ public void CanDispatchToNamedFormInNestedContext() DispatchToFormCore(dispatchToForm); } + [Fact] + public void CanBindFormValueFromNestedNamedFormWithBody() + { + var dispatchToForm = new DispatchToForm(this) + { + Url = "forms/nested-named-form-bound-parameter", + FormCssSelector = """form[name="parent-context.named-form-handler"]""", + ExpectedActionValue = "forms/nested-named-form-bound-parameter?handler=parent-context.named-form-handler", + InputFieldId = "value", + InputFieldCssSelector = "input[name=value]", + InputFieldValue = "stranger", + }; + DispatchToFormCore(dispatchToForm); + } + [Fact] public void CanDispatchToFormDefinedInNonPageComponent() { @@ -251,7 +295,11 @@ private void DispatchToFormCore(DispatchToForm dispatch) if (dispatch.InputFieldValue != null) { - Browser.Exists(By.Id(dispatch.InputFieldId)).SendKeys(dispatch.InputFieldValue); + var criteria = dispatch.InputFieldCssSelector != null ? + By.CssSelector(dispatch.InputFieldCssSelector) : + By.Id(dispatch.InputFieldId); + + Browser.Exists(criteria).SendKeys(dispatch.InputFieldValue); } Browser.Click(By.Id(dispatch.SubmitButtonId)); @@ -284,6 +332,7 @@ public DispatchToForm(FormWithParentBindingContextTest test) : this() public string SubmitButtonId { get; internal set; } = "send"; public string InputFieldId { get; internal set; } = "firstName"; + public string InputFieldCssSelector { get; internal set; } = null; } private void GoTo(string relativePath) diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Components/ComponentWithFormBoundParameter.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Components/ComponentWithFormBoundParameter.razor new file mode 100644 index 000000000000..1b55105e58b4 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Components/ComponentWithFormBoundParameter.razor @@ -0,0 +1,17 @@ +@using Microsoft.AspNetCore.Components.Forms + + + + + + +@if (_submitted) +{ +

Hello @Parameter!

+} + +@code{ + bool _submitted = false; + + [SupplyParameterFromForm(Name = "named-form-handler")] public string Parameter { get; set; } = ""; +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/FormWithoutBindingContextApp.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/FormWithDefaultContextApp.razor similarity index 91% rename from src/Components/test/testassets/Components.TestServer/RazorComponents/FormWithoutBindingContextApp.razor rename to src/Components/test/testassets/Components.TestServer/RazorComponents/FormWithDefaultContextApp.razor index ec4501fa7209..ab400f1f3543 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/FormWithoutBindingContextApp.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/FormWithDefaultContextApp.razor @@ -9,7 +9,7 @@ - + diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/DefaultFormBoundParameter.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/DefaultFormBoundParameter.razor new file mode 100644 index 000000000000..0cda2a205275 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/DefaultFormBoundParameter.razor @@ -0,0 +1,20 @@ +@page "/forms/default-form-bound-parameter" +@using Microsoft.AspNetCore.Components.Forms + +

Default form with bound parameter

+ + + + + + +@if (_submitted) +{ +

Hello @Parameter!

+} + +@code { + bool _submitted = false; + + [SupplyParameterFromForm] public string Parameter { get; set; } = ""; +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/NamedFormBoundParameter.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/NamedFormBoundParameter.razor new file mode 100644 index 000000000000..55d1adf585fa --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/NamedFormBoundParameter.razor @@ -0,0 +1,20 @@ +@page "/forms/named-form-bound-parameter" +@using Microsoft.AspNetCore.Components.Forms + +

Named form with bound parameter

+ + + + + + +@if (_submitted) +{ +

Hello @Parameter!

+} + +@code{ + bool _submitted = false; + + [SupplyParameterFromForm(Name = "named-form-handler")] public string Parameter { get; set; } = ""; +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/NestedNamedFormBoundParameter.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/NestedNamedFormBoundParameter.razor new file mode 100644 index 000000000000..29750b239c0d --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/NestedNamedFormBoundParameter.razor @@ -0,0 +1,8 @@ +@page "/forms/nested-named-form-bound-parameter" +@using Microsoft.AspNetCore.Components.Forms + +

Nested named form bound parameter

+ + + +