diff --git a/src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs b/src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs index e15b90c0e42d..b487eff8deea 100644 --- a/src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs +++ b/src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs @@ -1,12 +1,18 @@ // 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; using System.Collections.Concurrent; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Reflection; using System.Reflection.Metadata; using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Http.Validation; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; [assembly: MetadataUpdateHandler(typeof(Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions))] @@ -15,7 +21,7 @@ namespace Microsoft.AspNetCore.Components.Forms; /// /// Extension methods to add DataAnnotations validation to an . /// -public static class EditContextDataAnnotationsExtensions +public static partial class EditContextDataAnnotationsExtensions { /// /// Adds DataAnnotations validation support to the . @@ -59,7 +65,7 @@ private static void ClearCache(Type[]? _) } #pragma warning restore IDE0051 // Remove unused private members - private sealed class DataAnnotationsEventSubscriptions : IDisposable + private sealed partial class DataAnnotationsEventSubscriptions : IDisposable { private static readonly ConcurrentDictionary<(Type ModelType, string FieldName), PropertyInfo?> _propertyInfoCache = new(); @@ -82,6 +88,7 @@ public DataAnnotationsEventSubscriptions(EditContext editContext, IServiceProvid } } + // TODO(OR): Should this also use ValidatablePropertyInfo.ValidateAsync? [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Model types are expected to be defined in assemblies that do not get trimmed.")] private void OnFieldChanged(object? sender, FieldChangedEventArgs eventArgs) { @@ -112,6 +119,18 @@ private void OnFieldChanged(object? sender, FieldChangedEventArgs eventArgs) private void OnValidationRequested(object? sender, ValidationRequestedEventArgs e) { var validationContext = new ValidationContext(_editContext.Model, _serviceProvider, items: null); + + if (!TryValidateTypeInfo(validationContext)) + { + ValidateWithDefaultValidator(validationContext); + } + + _editContext.NotifyValidationStateChanged(); + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Model types are expected to be defined in assemblies that do not get trimmed.")] + private void ValidateWithDefaultValidator(ValidationContext validationContext) + { var validationResults = new List(); Validator.TryValidateObject(_editContext.Model, validationContext, validationResults, true); @@ -136,8 +155,125 @@ private void OnValidationRequested(object? sender, ValidationRequestedEventArgs _messages.Add(new FieldIdentifier(_editContext.Model, fieldName: string.Empty), validationResult.ErrorMessage!); } } + } - _editContext.NotifyValidationStateChanged(); +#pragma warning disable ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + private bool TryValidateTypeInfo(ValidationContext validationContext) + { + var options = _serviceProvider?.GetService>()?.Value; + + if (options == null || !options.TryGetValidatableTypeInfo(_editContext.Model.GetType(), out var typeInfo)) + { + return false; + } + + var validateContext = new ValidateContext + { + ValidationOptions = options, + ValidationContext = validationContext, + }; + + var containerMapping = new Dictionary(); + + validateContext.OnValidationError += (key, _, container) => containerMapping[key] = container; + + var validationTask = typeInfo.ValidateAsync(_editContext.Model, validateContext, CancellationToken.None); + + if (!validationTask.IsCompleted) + { + throw new InvalidOperationException("Async validation is not supported"); + } + + var validationErrors = validateContext.ValidationErrors; + + // Transfer results to the ValidationMessageStore + _messages.Clear(); + + if (validationErrors is not null && validationErrors.Count > 0) + { + foreach (var (fieldKey, messages) in validationErrors) + { + // Reverse mapping based on storing references during validation. + // With this approach, we could skip iterating over ValidateContext.ValidationErrors and pass the errors + // directly to ValidationMessageStore in the OnValidationError handler. + var fieldContainer = containerMapping[fieldKey] ?? _editContext.Model; + + // Alternative: Reverse mapping based on object graph walk. + //var fieldContainer = GetFieldContainer(_editContext.Model, fieldKey); + + var lastDotIndex = fieldKey.LastIndexOf('.'); + var fieldName = lastDotIndex >= 0 ? fieldKey[(lastDotIndex + 1)..] : fieldKey; + + _messages.Add(new FieldIdentifier(fieldContainer, fieldName), messages); + } + } + + return true; + } +#pragma warning restore ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + + // TODO(OR): Replace this with a more robust implementation or a different approach. E.g. collect references during the validation process itself. + [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "Model types are expected to be defined in assemblies that do not get trimmed.")] + private static object GetFieldContainer(object obj, string fieldKey) + { + // The method does not check all possible null access and index bound errors as the path is constructed internally and assumed to be correct. + var dotSegments = fieldKey.Split('.')[..^1]; + var currentObject = obj; + + for (int i = 0; i < dotSegments.Length; i++) + { + string segment = dotSegments[i]; + + if (currentObject == null) + { + string traversedPath = string.Join(".", dotSegments.Take(i)); + throw new ArgumentException($"Cannot access segment '{segment}' because the path '{traversedPath}' resolved to null."); + } + + Match match = _pathSegmentRegex.Match(segment); + if (!match.Success) + { + throw new ArgumentException($"Invalid path segment: '{segment}'."); + } + + string propertyName = match.Groups[1].Value; + string? indexStr = match.Groups[2].Success ? match.Groups[2].Value : null; + + Type currentType = currentObject.GetType(); + PropertyInfo propertyInfo = currentType!.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance)!; + object propertyValue = propertyInfo!.GetValue(currentObject)!; + + if (indexStr == null) // Simple property access + { + currentObject = propertyValue; + } + else // Indexed access + { + if (!int.TryParse(indexStr, out int index)) + { + throw new ArgumentException($"Invalid index '{indexStr}' in segment '{segment}'."); + } + + if (propertyValue is Array array) + { + currentObject = array.GetValue(index)!; + } + else if (propertyValue is IList list) + { + currentObject = list[index]!; + } + else if (propertyValue is IEnumerable enumerable) + { + currentObject = enumerable.Cast().ElementAt(index); + } + else + { + throw new ArgumentException($"Property '{propertyName}' is not an array, list, or enumerable. Cannot access by index in segment '{segment}'."); + } + } + + } + return currentObject!; } public void Dispose() @@ -174,5 +310,11 @@ internal void ClearCache() { _propertyInfoCache.Clear(); } + + private static readonly Regex _pathSegmentRegex = PathSegmentRegexGen(); + + // Regex to parse "PropertyName" or "PropertyName[index]" + [GeneratedRegex(@"^([a-zA-Z_]\w*)(?:\[(\d+)\])?$", RegexOptions.Compiled)] + private static partial Regex PathSegmentRegexGen(); } } diff --git a/src/Components/Forms/src/Microsoft.AspNetCore.Components.Forms.csproj b/src/Components/Forms/src/Microsoft.AspNetCore.Components.Forms.csproj index 654c4ac2d3fe..45643f54d35e 100644 --- a/src/Components/Forms/src/Microsoft.AspNetCore.Components.Forms.csproj +++ b/src/Components/Forms/src/Microsoft.AspNetCore.Components.Forms.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Components/Samples/BlazorUnitedApp/BlazorUnitedApp.csproj b/src/Components/Samples/BlazorUnitedApp/BlazorUnitedApp.csproj index 62460261002b..cf687ec7ba1c 100644 --- a/src/Components/Samples/BlazorUnitedApp/BlazorUnitedApp.csproj +++ b/src/Components/Samples/BlazorUnitedApp/BlazorUnitedApp.csproj @@ -4,12 +4,21 @@ $(DefaultNetCoreTargetFramework) false enable + + $(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Validation.Generated + true + + + + diff --git a/src/Components/Samples/BlazorUnitedApp/Pages/ComplexFormPage.razor b/src/Components/Samples/BlazorUnitedApp/Pages/ComplexFormPage.razor new file mode 100644 index 000000000000..525cd26a2fa4 --- /dev/null +++ b/src/Components/Samples/BlazorUnitedApp/Pages/ComplexFormPage.razor @@ -0,0 +1,163 @@ +@page "/complex-form" +@rendermode InteractiveServer + +@using System.ComponentModel.DataAnnotations +@using BlazorUnitedApp.Validation + +Validated Order Form + + + + +
+

Order Details

+
+ + + +
+ +
+ +

Customer Details

+
+
+
+ + + +
+
+ + + +
+ +
Shipping Address
+
+
+
+
+ + + +
+
+ + + +
+
+
+
+
+
+ +
+ +

Order Items

+ @if (order.OrderItems.Any()) + { + for (int i = 0; i < order.OrderItems.Count; i++) + { + var itemIndex = i; +
+
+ Item @(itemIndex + 1) + +
+
+
+
+ + + +
+
+ + + +
+
+
+
+ } + } + else + { +

No order items. Add one below.

+ } + + + +
+ +
+ +
+ + +
+
+ +@if (submitted) +{ + +} + +@if (submitFailed) +{ + +} + + +@code { + private OrderModel order = new OrderModel(); + private bool submitted = false; + private bool submitFailed = false; + + private void HandleValidSubmit() + { + Console.WriteLine("Form submitted successfully!"); + submitted = true; + submitFailed = false; + } + + private void HandleInvalidSubmit() + { + Console.WriteLine("Form submission failed due to validation errors."); + submitted = false; + submitFailed = true; + } + + private void AddOrderItem() + { + order.OrderItems.Add(new OrderItemModel()); + submitted = false; + submitFailed = false; + } + + private void RemoveOrderItem(int index) + { + if (index >= 0 && index < order.OrderItems.Count) + { + order.OrderItems.RemoveAt(index); + } + submitted = false; + submitFailed = false; + } +} diff --git a/src/Components/Samples/BlazorUnitedApp/Pages/Index.razor b/src/Components/Samples/BlazorUnitedApp/Pages/Index.razor index 6604c511e2ba..caa747cb9240 100644 --- a/src/Components/Samples/BlazorUnitedApp/Pages/Index.razor +++ b/src/Components/Samples/BlazorUnitedApp/Pages/Index.razor @@ -2,39 +2,4 @@ @using BlazorUnitedApp.Data; Index - -
- -
- - -
- -@if (_submitted) -{ - -

Customer

-

Name: @Value!.Name

-

Street: @Value.BillingAddress.Street

-

City: @Value.BillingAddress.City

-

State: @Value.BillingAddress.State

-

Zip: @Value.BillingAddress.Zip

-} - -@code { - - public void DisplayCustomer() - { - _submitted = true; - } - - [SupplyParameterFromForm] Customer? Value { get; set; } - - protected override void OnInitialized() => Value ??= new(); - - bool _submitted = false; - public void Submit() => _submitted = true; -} +

Welcome to Blazor

diff --git a/src/Components/Samples/BlazorUnitedApp/Program.cs b/src/Components/Samples/BlazorUnitedApp/Program.cs index 6492f3fb3e50..9fb1293a52ab 100644 --- a/src/Components/Samples/BlazorUnitedApp/Program.cs +++ b/src/Components/Samples/BlazorUnitedApp/Program.cs @@ -13,6 +13,8 @@ builder.Services.AddSingleton(); +builder.Services.AddValidation(); + var app = builder.Build(); // Configure the HTTP request pipeline. diff --git a/src/Components/Samples/BlazorUnitedApp/Shared/NavMenu.razor b/src/Components/Samples/BlazorUnitedApp/Shared/NavMenu.razor index 3801dfc18932..b5e52b76837d 100644 --- a/src/Components/Samples/BlazorUnitedApp/Shared/NavMenu.razor +++ b/src/Components/Samples/BlazorUnitedApp/Shared/NavMenu.razor @@ -14,6 +14,11 @@ Home +