diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs index 2119368c9b82..44ea701f832a 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Components.Endpoints.DependencyInjection; using Microsoft.AspNetCore.Components.Endpoints.Forms; using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Components.Forms.ClientValidation; using Microsoft.AspNetCore.Components.Forms.Mapping; using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.Routing; @@ -94,7 +95,8 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(services, RenderMode.InteractiveAuto); services.TryAddScoped(); services.TryAddScoped(); - services.AddClientValidation(); + services.TryAddSingleton(); + services.TryAddScoped(); if (configure != null) { diff --git a/src/Components/Endpoints/src/Forms/ClientValidationCache.cs b/src/Components/Endpoints/src/Forms/ClientValidationCache.cs new file mode 100644 index 000000000000..4868f769a829 --- /dev/null +++ b/src/Components/Endpoints/src/Forms/ClientValidationCache.cs @@ -0,0 +1,170 @@ +// 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.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Components.HotReload; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Validation; + +namespace Microsoft.AspNetCore.Components.Endpoints.Forms; + +using FieldKey = (Type ModelType, string FieldName); + +internal sealed class ClientValidationCache : IDisposable +{ + private readonly ConcurrentDictionary _metadataCache = new(); + private readonly ConcurrentDictionary _typeHasValidatableInfo = new(); + private readonly ConcurrentDictionary _propertyHasValidatableInfo = new(); + private readonly ValidationOptions _validationOptions; + + [UnconditionalSuppressMessage("Trimming", "IL2066", + Justification = "Preserves ValidationOptions's parameterless constructor used by Microsoft.Extensions.Options to materialize IOptions.")] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(ValidationOptions))] + public ClientValidationCache(IOptions validationOptions) + { + _validationOptions = validationOptions.Value; + + if (HotReloadManager.IsSupported) + { + HotReloadManager.Default.OnDeltaApplied += ClearCache; + } + } + + public IEnumerable<(string renderedName, ClientValidationFieldMetadata metadata)> GetValidatableFieldMetadata( + IReadOnlyDictionary fields, + object formModel) + { + // The form model type determines which validation pipeline (DataAnnotations.Validator vs MEV) the server uses. + var formHasValidatableInfo = HasValidatableTypeInfo(formModel.GetType()); + + foreach (var (fieldIdentifier, renderedName) in fields) + { + // Don't enable client-side validation for fields that would not get server-side validation + // to help developers avoid security mistakes. + if (!IsServerValidatable(fieldIdentifier, formModel, formHasValidatableInfo)) + { + continue; + } + + var fieldKey = (fieldIdentifier.Model.GetType(), fieldIdentifier.FieldName); + var cachedMetadata = _metadataCache.GetOrAdd( + fieldKey, + static key => BuildFieldMetadata(key.ModelType, key.FieldName)); + + if (cachedMetadata is { } fieldMetadata) + { + yield return (renderedName, fieldMetadata); + } + } + } + + /// + /// Determines whether the server will validate the specified field on submit, which is the + /// condition under which client-side rules may be safely emitted. + /// + /// The field an input was rendered for. + /// The form's top-level model (). + /// + /// Whether the form model type is recognized by MEV. Computed once per form by the caller via + /// and threaded in so the per-field path does no extra + /// type-level lookup. + /// + private bool IsServerValidatable(in FieldIdentifier fieldIdentifier, object formModel, bool formHasValidatableInfo) + { + if (formHasValidatableInfo) + { + // MEV submit path (ValidateAsync) recurses. A field is validated iff MEV has + // ValidatablePropertyInfo for it on its owner type. This also naturally excludes + // members/types filtered by [SkipValidation], matching the server. + return HasValidatablePropertyInfo(fieldIdentifier.Model.GetType(), fieldIdentifier.FieldName); + } + else + { + // DataAnnotations submit path (Validator.TryValidateObject) validates only the form + // model's top-level properties and does not recurse. A field is top-level iff its owner + // is the form model instance. + return ReferenceEquals(fieldIdentifier.Model, formModel); + } + } + +#pragma warning disable ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. + /// + /// Returns whether MEV recognizes as a validatable type. Cached. + /// Returns when MEV is not configured. + /// + private bool HasValidatableTypeInfo(Type type) => + _validationOptions.Resolvers.Count > 0 + && _typeHasValidatableInfo.GetOrAdd(type, + key => _validationOptions.TryGetValidatableTypeInfo(key, out _)); + + private bool HasValidatablePropertyInfo(Type ownerType, string fieldName) => + _validationOptions.Resolvers.Count > 0 + && _propertyHasValidatableInfo.GetOrAdd((ownerType, fieldName), + key => _validationOptions.TryGetValidatableTypeInfo(key.ModelType, out var typeInfo) + && typeInfo.TryFindProperty(key.FieldName, _validationOptions, out _)); +#pragma warning restore ASP0029 + + private void ClearCache() + { + _metadataCache.Clear(); + _typeHasValidatableInfo.Clear(); + _propertyHasValidatableInfo.Clear(); + } + + public void Dispose() + { + if (HotReloadManager.IsSupported) + { + HotReloadManager.Default.OnDeltaApplied -= ClearCache; + } + } + + // Builds reflection metadata for a single field. Culture-independent; localized text is + // resolved per call by the provider. At most one of ResourceDisplayAttribute and + // LiteralDisplayName is non-null; both null means the property has no display attribute. + [UnconditionalSuppressMessage("Trimming", "IL2070", + Justification = "Model types are application code and are preserved by default.")] + private static ClientValidationFieldMetadata? BuildFieldMetadata(Type modelType, string propertyName) + { + var property = modelType.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); + + if (property is null) + { + return null; + } + + var validationAttributes = property.GetCustomAttributes(inherit: true).ToArray(); + + if (validationAttributes.Length == 0) + { + return null; + } + + var displayAttribute = property.GetCustomAttribute(inherit: true); + DisplayAttribute? resourceDisplayAttribute = null; + string? literalDisplayName = null; + + if (displayAttribute is { ResourceType: not null, Name: not null }) + { + resourceDisplayAttribute = displayAttribute; + } + else + { + literalDisplayName = displayAttribute?.Name + ?? property.GetCustomAttribute(inherit: true)?.DisplayName; + } + + return new ClientValidationFieldMetadata( + propertyName: property.Name, + validationAttributes, + declaringType: property.DeclaringType, + resourceDisplayAttribute, + literalDisplayName); + } +} diff --git a/src/Components/Endpoints/src/Forms/ClientValidationDataSerializer.cs b/src/Components/Endpoints/src/Forms/ClientValidationDataSerializer.cs new file mode 100644 index 000000000000..67e78f556b91 --- /dev/null +++ b/src/Components/Endpoints/src/Forms/ClientValidationDataSerializer.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Text; +using System.Text.Json; + +namespace Microsoft.AspNetCore.Components.Endpoints.Forms; + +internal static class ClientValidationDataSerializer +{ + public static string Serialize(ClientValidationFormDescriptor form) + { + var buffer = new ArrayBufferWriter(initialCapacity: 256); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartObject(); + writer.WritePropertyName("fields"u8); + writer.WriteStartArray(); + foreach (var field in form.Fields) + { + WriteField(writer, field); + } + writer.WriteEndArray(); + writer.WriteEndObject(); + } + // Utf8JsonWriter's default encoder escapes <, >, &, ' - safe for HTML text content. + return Encoding.UTF8.GetString(buffer.WrittenSpan); + } + + private static void WriteField(Utf8JsonWriter writer, ClientValidationFieldDescriptor field) + { + writer.WriteStartObject(); + writer.WriteString("name"u8, field.Name); + writer.WritePropertyName("rules"u8); + writer.WriteStartArray(); + foreach (var rule in field.Rules) + { + WriteRule(writer, rule); + } + writer.WriteEndArray(); + writer.WriteEndObject(); + } + + private static void WriteRule(Utf8JsonWriter writer, ClientValidationRuleDescriptor rule) + { + writer.WriteStartObject(); + writer.WriteString("name"u8, rule.Name); + writer.WriteString("message"u8, rule.ErrorMessage); + if (rule.Parameters is { Count: > 0 } parameters) + { + writer.WritePropertyName("params"u8); + writer.WriteStartObject(); + foreach (var kvp in parameters) + { + writer.WriteString(kvp.Key, kvp.Value); + } + writer.WriteEndObject(); + } + writer.WriteEndObject(); + } +} diff --git a/src/Components/Endpoints/src/Forms/ClientValidationFieldDescriptor.cs b/src/Components/Endpoints/src/Forms/ClientValidationFieldDescriptor.cs new file mode 100644 index 000000000000..364f6d30e30a --- /dev/null +++ b/src/Components/Endpoints/src/Forms/ClientValidationFieldDescriptor.cs @@ -0,0 +1,34 @@ +// 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.Forms; + +/// +/// Describes the client-side validation rules for one field within a form. +/// +internal sealed class ClientValidationFieldDescriptor +{ + /// + /// Creates a field descriptor. + /// + /// The field name as it appears in form posts. Should use dotted path for nested fields (e.g. Address.Street). + /// The ordered list of client-side validation rules for this field. + public ClientValidationFieldDescriptor( + string name, + IReadOnlyList rules) + { + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentNullException.ThrowIfNull(rules); + Name = name; + Rules = rules; + } + + /// + /// Field name as it appears in form posts. + /// Dotted path for nested fields (e.g. Address.Street). + /// + public string Name { get; } + + /// Ordered list of client-side validation rules. + public IReadOnlyList Rules { get; } +} diff --git a/src/Components/Endpoints/src/Forms/ClientValidationFieldMetadata.cs b/src/Components/Endpoints/src/Forms/ClientValidationFieldMetadata.cs new file mode 100644 index 000000000000..b63613e623f2 --- /dev/null +++ b/src/Components/Endpoints/src/Forms/ClientValidationFieldMetadata.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.ComponentModel.DataAnnotations; + +namespace Microsoft.AspNetCore.Components.Endpoints.Forms; + +// Per-field reflection results. Culture-independent; localized text is resolved per call. +// At most one of ResourceDisplayAttribute and LiteralDisplayName is non-null; both null means +// the field property has no display attribute. +internal readonly struct ClientValidationFieldMetadata( + string propertyName, + ValidationAttribute[] validationAttributes, + Type? declaringType, + DisplayAttribute? resourceDisplayAttribute, + string? literalDisplayName) +{ + public string PropertyName { get; } = propertyName; + + public ValidationAttribute[] ValidationAttributes { get; } = validationAttributes; + + public Type? DeclaringType { get; } = declaringType; + + // [Display(Name=..., ResourceType=...)] + public DisplayAttribute? ResourceDisplayAttribute { get; } = resourceDisplayAttribute; + + // [Display(Name="X")] (no ResourceType) or [DisplayName("X")]. + public string? LiteralDisplayName { get; } = literalDisplayName; +} diff --git a/src/Components/Endpoints/src/Forms/ClientValidationFormDescriptor.cs b/src/Components/Endpoints/src/Forms/ClientValidationFormDescriptor.cs new file mode 100644 index 000000000000..c0c862689f4f --- /dev/null +++ b/src/Components/Endpoints/src/Forms/ClientValidationFormDescriptor.cs @@ -0,0 +1,25 @@ +// 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.Forms; + +/// +/// Describes the client-side validation data for one form: the set of validated fields +/// and their rules. +/// +internal sealed class ClientValidationFormDescriptor +{ + /// + /// Creates a descriptor with the given field descriptors. + /// + public ClientValidationFormDescriptor(IReadOnlyList fields) + { + ArgumentNullException.ThrowIfNull(fields); + Fields = fields; + } + + /// + /// Per-field client-side validation data. + /// + public IReadOnlyList Fields { get; } +} diff --git a/src/Components/Endpoints/src/Forms/ClientValidationRule.cs b/src/Components/Endpoints/src/Forms/ClientValidationRule.cs new file mode 100644 index 000000000000..5029be7f7ded --- /dev/null +++ b/src/Components/Endpoints/src/Forms/ClientValidationRule.cs @@ -0,0 +1,44 @@ +// 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.Forms; + +/// +/// Describes a single client-side validation rule produced by an . +/// +public sealed class ClientValidationRule +{ + /// + /// Creates a rule with the specified name and optional parameters. + /// + /// + /// The rule name. Must be non-empty and must match the name registered with the JS + /// validator via Blazor.formValidation.addValidator(name, ...). + /// + /// + /// Optional parameters passed to the JS validator at runtime. All values are strings on the + /// wire; validators that need numeric or boolean values parse them at validation time + /// (parseInt, parseFloat, etc.). When or empty, the + /// params object is omitted from the wire format. + /// + public ClientValidationRule( + string name, + IReadOnlyDictionary? parameters = null) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + Name = name; + Parameters = parameters; + } + + /// + /// Gets the rule name. Matches the name registered with the JS validator. + /// + public string Name { get; } + + /// + /// Gets the parameters passed to the JS validator at runtime. when + /// no parameters apply. + /// + public IReadOnlyDictionary? Parameters { get; } +} diff --git a/src/Components/Endpoints/src/Forms/ClientValidationRuleDescriptor.cs b/src/Components/Endpoints/src/Forms/ClientValidationRuleDescriptor.cs new file mode 100644 index 000000000000..faa67652a5eb --- /dev/null +++ b/src/Components/Endpoints/src/Forms/ClientValidationRuleDescriptor.cs @@ -0,0 +1,27 @@ +// 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.Forms; + +// Describes a single client-side validation rule including the resolved error message. +internal sealed class ClientValidationRuleDescriptor +{ + public ClientValidationRuleDescriptor( + string name, + string errorMessage, + IReadOnlyDictionary? parameters = null) + { + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentNullException.ThrowIfNull(errorMessage); + + Name = name; + ErrorMessage = errorMessage; + Parameters = parameters; + } + + public string Name { get; } + + public string ErrorMessage { get; } + + public IReadOnlyDictionary? Parameters { get; } +} diff --git a/src/Components/Endpoints/src/Forms/DataAnnotationsClientValidationProvider.cs b/src/Components/Endpoints/src/Forms/DataAnnotationsClientValidationProvider.cs new file mode 100644 index 000000000000..6ea7ea6acc52 --- /dev/null +++ b/src/Components/Endpoints/src/Forms/DataAnnotationsClientValidationProvider.cs @@ -0,0 +1,262 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Components.Forms.ClientValidation; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Validation; + +namespace Microsoft.AspNetCore.Components.Endpoints.Forms; + +/// +/// Iterates the inputs registered on the and builds a +/// describing client-side validation rules. +/// Emits client-side validation rules only for fields that would be validated on the server as well. +/// +internal sealed class DataAnnotationsClientValidationProvider : ClientValidationProvider +{ + private readonly ClientValidationCache _clientValidationCache; + private readonly IValidationLocalizer? _validationLocalizer; + + [UnconditionalSuppressMessage("Trimming", "IL2066", Justification = "Preserves ValidationOptions's parameterless constructor used by Microsoft.Extensions.Options to materialize IOptions.")] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(ValidationOptions))] + public DataAnnotationsClientValidationProvider(ClientValidationCache clientValidationCache, IOptions validationOptions) + { + _clientValidationCache = clientValidationCache; + _validationLocalizer = validationOptions.Value.Localizer; + } + + public override RenderFragment? RenderClientValidationRules(EditContext editContext, IReadOnlyDictionary renderedFields) + { + var formDescriptor = BuildFormDescriptor(editContext, renderedFields); + if (formDescriptor is null) + { + return null; + } + + var json = ClientValidationDataSerializer.Serialize(formDescriptor); + + return builder => + { + builder.OpenElement(0, "blazor-client-validation-data"); + // The rules are carried in the data-rules attribute, not as element content, so the + // element renders nothing at all in the DOM. + builder.AddAttribute(1, "data-rules", json); + builder.CloseElement(); + }; + } + + // Builds the typed descriptor of the client-side rules for the rendered fields, or null when + // there is nothing to emit. Separated from RenderClientValidationRules so the rule-building + // (the reflection/attribute-mapping logic) can be unit-tested without going through the wire + // format and the carrier RenderFragment. + internal ClientValidationFormDescriptor? BuildFormDescriptor(EditContext editContext, IReadOnlyDictionary renderedFields) + { + ArgumentNullException.ThrowIfNull(editContext); + + if (renderedFields.Count == 0) + { + return null; + } + + List? fieldDescriptors = null; + var validatableFields = _clientValidationCache.GetValidatableFieldMetadata(renderedFields, editContext.Model); + + foreach (var (renderedName, fieldMetadata) in validatableFields) + { + if (BuildFieldDescriptor(renderedName, fieldMetadata) is { } fieldDescriptor) + { + (fieldDescriptors ??= []).Add(fieldDescriptor); + } + } + + return fieldDescriptors is null ? null : new ClientValidationFormDescriptor(fieldDescriptors); + } + + private ClientValidationFieldDescriptor? BuildFieldDescriptor(string renderedName, ClientValidationFieldMetadata fieldMetadata) + { + var displayName = ResolveDisplayName(fieldMetadata); + var rules = new List(); + + foreach (var attribute in fieldMetadata.ValidationAttributes) + { + var errorMessage = ResolveErrorMessage(attribute, fieldMetadata.PropertyName, displayName, fieldMetadata.DeclaringType); + + if (GetBuiltInValidationRule(attribute, errorMessage) is { } rule) + { + rules.Add(rule); + } + else if (attribute is IClientValidationAdapter adapter) + { + foreach (var customRule in adapter.GetClientValidationRules()) + { + var ruleDescriptor = new ClientValidationRuleDescriptor(customRule.Name, errorMessage, customRule.Parameters); + rules.Add(ruleDescriptor); + } + } + } + + return rules.Count > 0 + ? new ClientValidationFieldDescriptor(renderedName, rules) + : null; + } + + // Maps each built-in ValidationAttribute to its single rule descriptor. Custom + // attributes that implement IClientValidationAdapter contribute their own rules elsewhere. + private static ClientValidationRuleDescriptor? GetBuiltInValidationRule(ValidationAttribute validationAttribute, string errorMessage) + { + return validationAttribute switch + { + RequiredAttribute => new ClientValidationRuleDescriptor("required", errorMessage), + StringLengthAttribute sla => new ClientValidationRuleDescriptor("length", errorMessage, GetStringLengthParameters(sla)), + MaxLengthAttribute maxla => new ClientValidationRuleDescriptor("maxlength", errorMessage, + new Dictionary + { + ["max"] = maxla.Length.ToString(CultureInfo.InvariantCulture), + }), + MinLengthAttribute minla => new ClientValidationRuleDescriptor("minlength", errorMessage, + new Dictionary + { + ["min"] = minla.Length.ToString(CultureInfo.InvariantCulture), + }), + // The JS range validator is numeric-only (uses Number()); skip non-numeric operands. + RangeAttribute ra when IsNumericRangeOperand(ra.OperandType) => GetRangeRule(ra, errorMessage), + RegularExpressionAttribute rea => new ClientValidationRuleDescriptor("regex", errorMessage, + new Dictionary + { + ["pattern"] = rea.Pattern, + }), + CompareAttribute ca => new ClientValidationRuleDescriptor("equalto", errorMessage, + new Dictionary + { + // "*." prefix tells the JS equalto validator to resolve the other field + // relative to the current field's name prefix. + ["other"] = "*." + ca.OtherProperty, + }), + EmailAddressAttribute => new ClientValidationRuleDescriptor("email", errorMessage), + UrlAttribute => new ClientValidationRuleDescriptor("url", errorMessage), + PhoneAttribute => new ClientValidationRuleDescriptor("phone", errorMessage), + CreditCardAttribute => new ClientValidationRuleDescriptor("creditcard", errorMessage), + FileExtensionsAttribute fea => new ClientValidationRuleDescriptor("fileextensions", errorMessage, + new Dictionary + { + ["extensions"] = GetNormalizedExtensions(fea), + }), + _ => null, + }; + } + + private static Dictionary? GetStringLengthParameters(StringLengthAttribute sla) + { + var hasMax = sla.MaximumLength != int.MaxValue; + var hasMin = sla.MinimumLength != 0; + return (hasMax, hasMin) switch + { + (true, true) => new Dictionary + { + ["max"] = sla.MaximumLength.ToString(CultureInfo.InvariantCulture), + ["min"] = sla.MinimumLength.ToString(CultureInfo.InvariantCulture), + }, + (true, false) => new Dictionary + { + ["max"] = sla.MaximumLength.ToString(CultureInfo.InvariantCulture), + }, + (false, true) => new Dictionary + { + ["min"] = sla.MinimumLength.ToString(CultureInfo.InvariantCulture), + }, + _ => null, + }; + } + + private static ClientValidationRuleDescriptor GetRangeRule(RangeAttribute ra, string errorMessage) + { + // Triggers RangeAttribute.SetupConversion() to convert string Min/Max to OperandType. + ra.IsValid(3); + return new ClientValidationRuleDescriptor("range", errorMessage, + new Dictionary + { + ["min"] = Convert.ToString(ra.Minimum, CultureInfo.InvariantCulture)!, + ["max"] = Convert.ToString(ra.Maximum, CultureInfo.InvariantCulture)!, + }); + } + + private static string GetNormalizedExtensions(FileExtensionsAttribute fea) + { + var normalizedExtensions = fea.Extensions + .Replace(" ", string.Empty) + .Replace(".", string.Empty) + .ToLowerInvariant(); + return string.Join(",", normalizedExtensions.Split(',').Select(e => "." + e)); + } + + // Mirrors the decision tree used by the server-side validation. + // Resource-attribute display names bypass the localizer (resource lookup is the canonical + // localized source). Literal display names act as both lookup key and fallback for the localizer. + private string ResolveDisplayName(in ClientValidationFieldMetadata metadata) + { + if (metadata.ResourceDisplayAttribute is { } resourceAttribute) + { + return resourceAttribute.GetName() ?? metadata.PropertyName; + } + + if (metadata.LiteralDisplayName is not { } literal) + { + return metadata.PropertyName; + } + + if (_validationLocalizer is null) + { + return literal; + } + + return _validationLocalizer.ResolveDisplayName(new DisplayNameLocalizationContext + { + Type = metadata.DeclaringType, + DisplayName = literal, + MemberName = metadata.PropertyName, + }) ?? literal; + } + + // Mirrors the decision tree used by the server-side validation. Falls back to + // FormatErrorMessage when no localizer is configured or the attribute already supplies + // resource-based localization. + private string ResolveErrorMessage( + ValidationAttribute attribute, + string fieldName, + string displayName, + Type? declaringType) + { + if (_validationLocalizer is null || attribute.ErrorMessageResourceType is not null) + { + return attribute.FormatErrorMessage(displayName); + } + + return _validationLocalizer.ResolveErrorMessage(new ErrorMessageLocalizationContext + { + MemberName = fieldName, + DisplayName = displayName, + DeclaringType = declaringType, + Attribute = attribute, + }) ?? attribute.FormatErrorMessage(displayName); + } + + // RangeAttribute supports non-numeric operand types (e.g., DateTime) that the JS validator + // can't compare; only emit "range" rules for numeric operand types. + private static bool IsNumericRangeOperand(Type operandType) + => operandType == typeof(int) + || operandType == typeof(long) + || operandType == typeof(short) + || operandType == typeof(byte) + || operandType == typeof(uint) + || operandType == typeof(ulong) + || operandType == typeof(ushort) + || operandType == typeof(sbyte) + || operandType == typeof(double) + || operandType == typeof(float) + || operandType == typeof(decimal); +} diff --git a/src/Components/Endpoints/src/Forms/IClientValidationAdapter.cs b/src/Components/Endpoints/src/Forms/IClientValidationAdapter.cs new file mode 100644 index 000000000000..12c26067a535 --- /dev/null +++ b/src/Components/Endpoints/src/Forms/IClientValidationAdapter.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.Forms; + +/// +/// Implemented by subclasses +/// that contribute client-side validation rules to Blazor SSR forms. +/// +/// +/// Attributes that implement this interface participate in the client-side validation pipeline +/// for forms rendered server-side. Rule names must match a validator registered on the JS side via +/// Blazor.formValidation.addValidator(name, ...). +/// +public interface IClientValidationAdapter +{ + /// + /// Produces the client-side validation rules for this attribute. + /// + IEnumerable GetClientValidationRules(); +} diff --git a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt index f5e0da288bf3..ff59a82b28bc 100644 --- a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt +++ b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt @@ -51,3 +51,9 @@ Microsoft.AspNetCore.Components.WebAssemblyBrowserOptions.ApplicationCulture.set Microsoft.AspNetCore.Components.WebAssemblyBrowserOptions.EnvironmentName.get -> string? Microsoft.AspNetCore.Components.WebAssemblyBrowserOptions.EnvironmentName.set -> void Microsoft.AspNetCore.Components.WebAssemblyBrowserOptions.EnvironmentVariables.get -> System.Collections.Generic.IDictionary! +Microsoft.AspNetCore.Components.Forms.ClientValidationRule +Microsoft.AspNetCore.Components.Forms.ClientValidationRule.Name.get -> string! +Microsoft.AspNetCore.Components.Forms.ClientValidationRule.Parameters.get -> System.Collections.Generic.IReadOnlyDictionary? +Microsoft.AspNetCore.Components.Forms.ClientValidationRule.ClientValidationRule(string! name, System.Collections.Generic.IReadOnlyDictionary? parameters = null) -> void +Microsoft.AspNetCore.Components.Forms.IClientValidationAdapter +Microsoft.AspNetCore.Components.Forms.IClientValidationAdapter.GetClientValidationRules() -> System.Collections.Generic.IEnumerable! diff --git a/src/Components/Endpoints/test/FormValidation/ClientValidationDataSerializerTest.cs b/src/Components/Endpoints/test/FormValidation/ClientValidationDataSerializerTest.cs new file mode 100644 index 000000000000..8eacebb3a98f --- /dev/null +++ b/src/Components/Endpoints/test/FormValidation/ClientValidationDataSerializerTest.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +namespace Microsoft.AspNetCore.Components.Endpoints.Forms; + +public class ClientValidationDataSerializerTest +{ + // Regression guard: the serializer must use Utf8JsonWriter's default HTML-safe encoder, + // not UnsafeRelaxedJsonEscaping. The payload sits in the data-rules attribute of + // ; without escaping, hostile strings could break out of the + // element or its attribute. + [Fact] + public void Serialize_EscapesHtmlSensitiveCharacters() + { + const string hostile = ""; + var descriptor = new ClientValidationFormDescriptor(new List + { + new(hostile, new List + { + new(hostile, hostile, new Dictionary { [hostile] = hostile }), + }), + }); + + var json = ClientValidationDataSerializer.Serialize(descriptor); + + Assert.DoesNotContain("<", json); + Assert.DoesNotContain(">", json); + Assert.DoesNotContain("'", json); + Assert.DoesNotContain("", json); + } +} diff --git a/src/Components/Endpoints/test/FormValidation/ClientValidationProviderTests.cs b/src/Components/Endpoints/test/FormValidation/ClientValidationProviderTests.cs new file mode 100644 index 000000000000..dedf59e59295 --- /dev/null +++ b/src/Components/Endpoints/test/FormValidation/ClientValidationProviderTests.cs @@ -0,0 +1,419 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Microsoft.AspNetCore.Components.Endpoints.Forms; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Validation; + +namespace Microsoft.AspNetCore.Components.Endpoints.Tests.FormValidation; + +// Integration tests for the SSR client-validation rule pipeline: +// DataAnnotationsClientValidationProvider + ClientValidationCache. These exercise the real reflection, +// rule mapping, server-validation gating, and localization, driven by the set of fields an input +// rendered for (the renderedFields map that ClientValidationData passes in). +public class ClientValidationProviderTests +{ + [Fact] + public void AllBuiltInValidators_ProduceExpectedRulesAndParameters() + { + var descriptor = GetDescriptor( + nameof(AllAttributesModel.Required), + nameof(AllAttributesModel.Length), + nameof(AllAttributesModel.MaxLen), + nameof(AllAttributesModel.MinLen), + nameof(AllAttributesModel.NumericRange), + nameof(AllAttributesModel.Pattern), + nameof(AllAttributesModel.Compared), + nameof(AllAttributesModel.Email), + nameof(AllAttributesModel.Website), + nameof(AllAttributesModel.PhoneNumber), + nameof(AllAttributesModel.Card), + nameof(AllAttributesModel.Upload)); + + Assert.NotNull(descriptor); + + Assert.Equal("required", SingleRule(descriptor!, nameof(AllAttributesModel.Required)).Name); + + var length = SingleRule(descriptor!, nameof(AllAttributesModel.Length)); + Assert.Equal("length", length.Name); + Assert.Equal("8", length.Parameters!["min"]); + Assert.Equal("100", length.Parameters!["max"]); + + var maxlen = SingleRule(descriptor!, nameof(AllAttributesModel.MaxLen)); + Assert.Equal("maxlength", maxlen.Name); + Assert.Equal("50", maxlen.Parameters!["max"]); + + var minlen = SingleRule(descriptor!, nameof(AllAttributesModel.MinLen)); + Assert.Equal("minlength", minlen.Name); + Assert.Equal("5", minlen.Parameters!["min"]); + + var range = SingleRule(descriptor!, nameof(AllAttributesModel.NumericRange)); + Assert.Equal("range", range.Name); + Assert.Equal("1", range.Parameters!["min"]); + Assert.Equal("10", range.Parameters!["max"]); + + var regex = SingleRule(descriptor!, nameof(AllAttributesModel.Pattern)); + Assert.Equal("regex", regex.Name); + Assert.Equal("[a-z]+", regex.Parameters!["pattern"]); + + var equalto = SingleRule(descriptor!, nameof(AllAttributesModel.Compared)); + Assert.Equal("equalto", equalto.Name); + Assert.Equal("*." + nameof(AllAttributesModel.Required), equalto.Parameters!["other"]); + + Assert.Equal("email", SingleRule(descriptor!, nameof(AllAttributesModel.Email)).Name); + Assert.Equal("url", SingleRule(descriptor!, nameof(AllAttributesModel.Website)).Name); + Assert.Equal("phone", SingleRule(descriptor!, nameof(AllAttributesModel.PhoneNumber)).Name); + Assert.Equal("creditcard", SingleRule(descriptor!, nameof(AllAttributesModel.Card)).Name); + + var file = SingleRule(descriptor!, nameof(AllAttributesModel.Upload)); + Assert.Equal("fileextensions", file.Name); + Assert.Equal(".png,.jpg", file.Parameters!["extensions"]); + + // Every rule carries a non-empty formatted error message. + Assert.All(descriptor!.Fields, f => Assert.All(f.Rules, r => Assert.False(string.IsNullOrEmpty(r.ErrorMessage)))); + } + + [Fact] + public void MultipleAttributesOnOneProperty_ProduceRulesInDeclarationOrder() + { + var descriptor = GetDescriptor(nameof(MultiAttributeModel.Email)); + + var field = Assert.Single(descriptor!.Fields); + Assert.Collection(field.Rules, + r => Assert.Equal("required", r.Name), + r => Assert.Equal("email", r.Name)); + } + + [Fact] + public void OnlyRenderedFields_AreEmitted() + { + // The model has two validated properties; only one input rendered, so only that field + // appears. This is the core behavior of render-driven generation. + var descriptor = GetDescriptor(nameof(TwoFieldModel.First)); + + var field = Assert.Single(descriptor!.Fields); + Assert.Equal(nameof(TwoFieldModel.First), field.Name); + } + + [Fact] + public void PropertyWithoutValidationAttributes_IsOmitted() + { + var descriptor = GetDescriptor(nameof(TwoFieldModel.Unvalidated)); + + Assert.Null(descriptor); + } + + [Fact] + public void EmptyRenderedFields_ReturnsNull() + { + var provider = CreateProvider(); + var model = new TwoFieldModel(); + + var descriptor = provider.BuildFormDescriptor(new EditContext(model), new Dictionary()); + + Assert.Null(descriptor); + } + + [Fact] + public void StringLength_OmitsSentinelBounds() + { + var maxOnly = SingleRule(GetDescriptor(nameof(StringLengthModel.MaxOnly))!, nameof(StringLengthModel.MaxOnly)); + Assert.Equal("length", maxOnly.Name); + Assert.True(maxOnly.Parameters!.ContainsKey("max")); + Assert.False(maxOnly.Parameters!.ContainsKey("min")); + + var minOnly = SingleRule(GetDescriptor(nameof(StringLengthModel.MinOnly))!, nameof(StringLengthModel.MinOnly)); + Assert.Equal("length", minOnly.Name); + Assert.True(minOnly.Parameters!.ContainsKey("min")); + Assert.False(minOnly.Parameters!.ContainsKey("max")); + } + + [Fact] + public void Range_WithNonNumericOperand_ProducesNoRule() + { + // The JS range validator is numeric-only; a DateTime range must not emit a rule, and + // because it is the only attribute, the field is omitted entirely. + var descriptor = GetDescriptor(nameof(DateRangeModel.Date)); + + Assert.Null(descriptor); + } + + [Fact] + public void CustomAdapterAttribute_ContributesItsRules() + { + var rule = SingleRule(GetDescriptor(nameof(CustomAdapterModel.Value))!, nameof(CustomAdapterModel.Value)); + + Assert.Equal("custom", rule.Name); + Assert.Equal("custom message", rule.ErrorMessage); + Assert.Equal("bar", rule.Parameters!["foo"]); + } + + [Fact] + public void DisplayNameAttribute_IsUsedInErrorMessage() + { + var rule = SingleRule(GetDescriptor(nameof(DisplayNameModel.Field))!, nameof(DisplayNameModel.Field)); + + Assert.Contains("Custom Label", rule.ErrorMessage); + } + + [Fact] + public void Localizer_PopulatesContextsAndLocalizesMessage() + { + var localizer = new RecordingLocalizer(); + var options = new ValidationOptions { Localizer = localizer }; + + var rule = SingleRule(GetDescriptor(options, nameof(DisplayNameModel.Field))!, nameof(DisplayNameModel.Field)); + + // The rule message is the localizer's output. + Assert.Equal("localized-error", rule.ErrorMessage); + + // The display-name context carries the literal display name as the lookup key. + Assert.Equal("Custom Label", localizer.LastDisplayContext!.Value.DisplayName); + Assert.Equal(nameof(DisplayNameModel.Field), localizer.LastDisplayContext!.Value.MemberName); + + // The error-message context carries the resolved display name, member, declaring type, and attribute. + Assert.Equal("localized-display", localizer.LastErrorContext!.Value.DisplayName); + Assert.Equal(nameof(DisplayNameModel.Field), localizer.LastErrorContext!.Value.MemberName); + Assert.Equal(typeof(DisplayNameModel), localizer.LastErrorContext!.Value.DeclaringType); + Assert.IsType(localizer.LastErrorContext!.Value.Attribute); + } + + [Fact] + public void TopLevelField_IsEmitted_WithoutMev() + { + // No MEV configured: the DataAnnotations submit path validates top-level properties, so a + // top-level field is emitted. + var descriptor = GetDescriptor(nameof(TwoFieldModel.First)); + + Assert.NotNull(descriptor); + Assert.Equal(nameof(TwoFieldModel.First), Assert.Single(descriptor!.Fields).Name); + } + + [Fact] + public void NestedField_IsSkipped_WithoutMev() + { + // No MEV configured: the DataAnnotations submit path does not recurse into nested + // sub-models, so a client rule must NOT be emitted for a nested field (it would reject a + // value the server silently accepts). + var provider = CreateProvider(); + var model = new ParentModel { Child = new ChildModel() }; + var fields = new Dictionary + { + [new FieldIdentifier(model.Child, nameof(ChildModel.Street))] = "Child.Street", + }; + + var descriptor = provider.BuildFormDescriptor(new EditContext(model), fields); + + Assert.Null(descriptor); + } + + [Fact] + public void NestedField_IsEmitted_WhenMevRecognizesOwningType() + { + var options = CreateMevOptions(typeof(ParentModel), (typeof(ChildModel), nameof(ChildModel.Street))); + var provider = CreateProvider(options); + var model = new ParentModel { Child = new ChildModel() }; + var fields = new Dictionary + { + [new FieldIdentifier(model.Child, nameof(ChildModel.Street))] = "Child.Street", + }; + + var descriptor = provider.BuildFormDescriptor(new EditContext(model), fields); + + var field = Assert.Single(descriptor!.Fields); + Assert.Equal("Child.Street", field.Name); + Assert.Equal("required", Assert.Single(field.Rules).Name); + } + + [Fact] + public void NestedField_IsSuppressed_WhenMevDoesNotRecognizeOwningType() + { + // MEV is configured and recognizes the form model, but NOT the nested child type. The + // submit-time MEV walk would not reach the nested property, so the client rule must be + // suppressed to preserve client/server parity. + var options = CreateMevOptions(typeof(ParentModel) /* ChildModel deliberately not registered */); + var provider = CreateProvider(options); + var model = new ParentModel { Child = new ChildModel() }; + var fields = new Dictionary + { + [new FieldIdentifier(model.Child, nameof(ChildModel.Street))] = "Child.Street", + }; + + var descriptor = provider.BuildFormDescriptor(new EditContext(model), fields); + + Assert.Null(descriptor); + } + + // ---- Helpers ---- + + private static DataAnnotationsClientValidationProvider CreateProvider(ValidationOptions? options = null) + { + var opts = Options.Create(options ?? new ValidationOptions()); + var cache = new ClientValidationCache(opts); + return new DataAnnotationsClientValidationProvider(cache, opts); + } + + private static ClientValidationFormDescriptor? GetDescriptor(params string[] fieldNames) + where TModel : new() + => GetDescriptor(options: null, fieldNames); + + private static ClientValidationFormDescriptor? GetDescriptor(ValidationOptions? options, params string[] fieldNames) + where TModel : new() + { + var provider = CreateProvider(options); + var model = new TModel(); + var fields = new Dictionary(); + foreach (var name in fieldNames) + { + fields[new FieldIdentifier(model, name)] = name; + } + return provider.BuildFormDescriptor(new EditContext(model), fields); + } + + private static ClientValidationRuleDescriptor SingleRule(ClientValidationFormDescriptor descriptor, string fieldName) + { + var field = Assert.Single(descriptor.Fields, f => f.Name == fieldName); + return Assert.Single(field.Rules); + } + +#pragma warning disable ASP0029 // Microsoft.Extensions.Validation evaluation APIs. + private static ValidationOptions CreateMevOptions(Type formModelType, params (Type Type, string Member)[] nestedMembers) + { + var map = new Dictionary + { + [formModelType] = new TestTypeInfo(formModelType, Array.Empty()), + }; + foreach (var (type, member) in nestedMembers) + { + map[type] = new TestTypeInfo(type, new[] { new TestPropertyInfo(type, typeof(string), member) }); + } + + var options = new ValidationOptions(); + options.Resolvers.Add(new TestResolver(map)); + return options; + } + + private sealed class TestResolver(Dictionary map) : IValidatableInfoResolver + { + public bool TryGetValidatableTypeInfo(Type type, [NotNullWhen(true)] out IValidatableTypeInfo? validatableInfo) + => map.TryGetValue(type, out validatableInfo); + + public bool TryGetValidatableParameterInfo(ParameterInfo parameterInfo, [NotNullWhen(true)] out IValidatableParameterInfo? validatableInfo) + { + validatableInfo = null; + return false; + } + } + + private sealed class TestTypeInfo(Type type, IReadOnlyList members) : ValidatableTypeInfo(type, members) + { + protected override ValidationAttribute[] GetValidationAttributes() => Array.Empty(); + } + + private sealed class TestPropertyInfo(Type declaringType, Type propertyType, string name) + : ValidatablePropertyInfo(declaringType, propertyType, name) + { + protected override ValidationAttribute[] GetValidationAttributes() => Array.Empty(); + } +#pragma warning restore ASP0029 + + private sealed class RecordingLocalizer : IValidationLocalizer + { + public DisplayNameLocalizationContext? LastDisplayContext { get; private set; } + public ErrorMessageLocalizationContext? LastErrorContext { get; private set; } + + public string? ResolveDisplayName(in DisplayNameLocalizationContext context) + { + LastDisplayContext = context; + return "localized-display"; + } + + public string? ResolveErrorMessage(in ErrorMessageLocalizationContext context) + { + LastErrorContext = context; + return "localized-error"; + } + } + + // ---- Test models ---- + + private sealed class AllAttributesModel + { + [Required] public string Required { get; set; } = ""; + [StringLength(100, MinimumLength = 8)] public string Length { get; set; } = ""; + [MaxLength(50)] public string MaxLen { get; set; } = ""; + [MinLength(5)] public string MinLen { get; set; } = ""; + [Range(1, 10)] public int NumericRange { get; set; } + [RegularExpression("[a-z]+")] public string Pattern { get; set; } = ""; + [Compare(nameof(Required))] public string Compared { get; set; } = ""; + [EmailAddress] public string Email { get; set; } = ""; + [Url] public string Website { get; set; } = ""; + [Phone] public string PhoneNumber { get; set; } = ""; + [CreditCard] public string Card { get; set; } = ""; + [FileExtensions(Extensions = "png,jpg")] public string Upload { get; set; } = ""; + } + + private sealed class MultiAttributeModel + { + [Required] + [EmailAddress] + public string Email { get; set; } = ""; + } + + private sealed class TwoFieldModel + { + [Required] public string First { get; set; } = ""; + [Required] public string Second { get; set; } = ""; + public string Unvalidated { get; set; } = ""; + } + + private sealed class StringLengthModel + { + [StringLength(50)] public string MaxOnly { get; set; } = ""; + [StringLength(int.MaxValue, MinimumLength = 5)] public string MinOnly { get; set; } = ""; + } + + private sealed class DateRangeModel + { + [Range(typeof(DateTime), "2020-01-01", "2020-12-31")] + public DateTime Date { get; set; } + } + + private sealed class DisplayNameModel + { + [Required] + [Display(Name = "Custom Label")] + public string Field { get; set; } = ""; + } + + private sealed class CustomAdapterModel + { + [CustomAdapter(ErrorMessage = "custom message")] + public string Value { get; set; } = ""; + } + + private sealed class CustomAdapterAttribute : ValidationAttribute, IClientValidationAdapter + { + public IEnumerable GetClientValidationRules() + { + yield return new ClientValidationRule("custom", + new Dictionary { ["foo"] = "bar" }); + } + } + + private sealed class ParentModel + { + public ChildModel Child { get; set; } = new(); + } + + private sealed class ChildModel + { + [Required] public string Street { get; set; } = ""; + } +} diff --git a/src/Components/Endpoints/test/Microsoft.AspNetCore.Components.Endpoints.Tests.csproj b/src/Components/Endpoints/test/Microsoft.AspNetCore.Components.Endpoints.Tests.csproj index 31e2630e55f7..22249cdddf10 100644 --- a/src/Components/Endpoints/test/Microsoft.AspNetCore.Components.Endpoints.Tests.csproj +++ b/src/Components/Endpoints/test/Microsoft.AspNetCore.Components.Endpoints.Tests.csproj @@ -1,4 +1,4 @@ - + $(DefaultNetCoreTargetFramework) diff --git a/src/Components/Endpoints/test/TestComponents/ClientValidationForm.razor b/src/Components/Endpoints/test/TestComponents/ClientValidationForm.razor new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/Components/Forms/src/ClientValidation/ClientValidationRule.cs b/src/Components/Forms/src/ClientValidation/ClientValidationRule.cs deleted file mode 100644 index f2086d39b2aa..000000000000 --- a/src/Components/Forms/src/ClientValidation/ClientValidationRule.cs +++ /dev/null @@ -1,67 +0,0 @@ -// 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.ObjectModel; - -namespace Microsoft.AspNetCore.Components.Forms.ClientValidation; - -/// -/// Describes a single client-side validation rule produced by an . -/// -public sealed class ClientValidationRule -{ - private static readonly IReadOnlyDictionary EmptyParameters = ReadOnlyDictionary.Empty; - - private Dictionary? _parameters; - - /// - /// Creates a new rule with the specified name and error message. - /// - /// - /// The rule name. Must be non-empty and must match the name recognized by the client-side validator JavaScript. - /// - /// - /// The formatted error message displayed when the rule fails. Must not be ; - /// pass only if an empty message is intentional. - /// - public ClientValidationRule(string name, string errorMessage) - { - ArgumentException.ThrowIfNullOrEmpty(name); - ArgumentNullException.ThrowIfNull(errorMessage); - - Name = name; - ErrorMessage = errorMessage; - } - - /// - /// Gets the rule name. Rendered as the suffix of data-val-<Name>. - /// - public string Name { get; } - - /// - /// Gets the formatted error message for this rule. Rendered as the value of data-val-<Name>. - /// - public string ErrorMessage { get; } - - /// - /// Gets the parameters associated with this rule, keyed by parameter name. - /// - public IReadOnlyDictionary Parameters => _parameters ?? EmptyParameters; - - /// - /// Adds or replaces a parameter on the rule. - /// - /// The parameter name. Must be non-empty. - /// - /// The parameter value. Must be JSON-primitive-shaped (string, bool, numeric primitives, - /// or ). - /// - /// The same rule, to allow fluent chaining. - public ClientValidationRule WithParameter(string name, object? value) - { - ArgumentException.ThrowIfNullOrEmpty(name); - - (_parameters ??= new Dictionary(StringComparer.Ordinal))[name] = value; - return this; - } -} diff --git a/src/Components/Forms/src/ClientValidation/ClientValidationServiceCollectionExtensions.cs b/src/Components/Forms/src/ClientValidation/ClientValidationServiceCollectionExtensions.cs deleted file mode 100644 index c0390f0553eb..000000000000 --- a/src/Components/Forms/src/ClientValidation/ClientValidationServiceCollectionExtensions.cs +++ /dev/null @@ -1,23 +0,0 @@ -// 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.ClientValidation; -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace Microsoft.Extensions.DependencyInjection; - -/// -/// Extension methods for registering client-side validation services. -/// -public static class ClientValidationServiceCollectionExtensions -{ - /// - /// Registers the default implementation, - /// which generates data-val-* HTML attributes from DataAnnotations validation attributes. - /// - public static IServiceCollection AddClientValidation(this IServiceCollection services) - { - services.TryAddSingleton(); - return services; - } -} diff --git a/src/Components/Forms/src/ClientValidation/DefaultClientValidationService.cs b/src/Components/Forms/src/ClientValidation/DefaultClientValidationService.cs deleted file mode 100644 index 5839ce6d8c96..000000000000 --- a/src/Components/Forms/src/ClientValidation/DefaultClientValidationService.cs +++ /dev/null @@ -1,307 +0,0 @@ -// 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.ComponentModel; -using System.ComponentModel.DataAnnotations; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Linq; -using System.Reflection; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Microsoft.Extensions.Validation; - -namespace Microsoft.AspNetCore.Components.Forms.ClientValidation; - -/// -/// Generates data-val-* HTML attributes from s on model properties. -/// -internal sealed class DefaultClientValidationService : IClientValidationService -{ - // Stores only culture-independent reflection results. Display name and error message text - // are resolved per call so the output respects CultureInfo.CurrentUICulture. - private readonly ConcurrentDictionary<(Type ModelType, string FieldName), FieldMetadata> _metadataCache = new(); - - private readonly IValidationLocalizer? _validationLocalizer; - - [UnconditionalSuppressMessage("Trimming", "IL2066", - Justification = "DynamicDependency preserves ValidationOptions's parameterless constructor used by Microsoft.Extensions.Options to materialize IOptions.")] - [DynamicDependency(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(ValidationOptions))] - public DefaultClientValidationService(IServiceProvider serviceProvider) - { - _validationLocalizer = serviceProvider.GetService>()?.Value?.Localizer; - } - - public IReadOnlyDictionary? GetClientValidationAttributes(FieldIdentifier fieldIdentifier) - { - var modelType = fieldIdentifier.Model.GetType(); - var cacheKey = (modelType, fieldIdentifier.FieldName); - var metadata = _metadataCache.GetOrAdd(cacheKey, static key => BuildMetadata(key.ModelType, key.FieldName)); - - if (metadata.ValidationAttributes.Length == 0) - { - return null; - } - - var displayName = ResolveDisplayName(in metadata, fieldIdentifier.FieldName); - var htmlAttributes = new Dictionary(); - - foreach (var validationAttribute in metadata.ValidationAttributes) - { - var errorMessage = ResolveErrorMessage(validationAttribute, fieldIdentifier.FieldName, displayName, metadata.DeclaringType); - AddAttributes(htmlAttributes, validationAttribute, errorMessage); - } - - if (htmlAttributes.Count == 0) - { - return null; - } - - htmlAttributes.TryAdd("data-val", "true"); - return htmlAttributes; - } - - // Maps each ValidationAttribute to its data-val-* keys. Custom attributes can implement - // IClientValidationAdapter for their own mappings. TryAdd is first-wins. - private static void AddAttributes( - Dictionary htmlAttributes, - ValidationAttribute validationAttribute, - string errorMessage) - { - switch (validationAttribute) - { - case RequiredAttribute: - htmlAttributes.TryAdd("data-val-required", errorMessage); - break; - - case StringLengthAttribute sla: - htmlAttributes.TryAdd("data-val-length", errorMessage); - if (sla.MaximumLength != int.MaxValue) - { - htmlAttributes.TryAdd("data-val-length-max", sla.MaximumLength.ToString(CultureInfo.InvariantCulture)); - } - if (sla.MinimumLength != 0) - { - htmlAttributes.TryAdd("data-val-length-min", sla.MinimumLength.ToString(CultureInfo.InvariantCulture)); - } - break; - - case MaxLengthAttribute maxla: - htmlAttributes.TryAdd("data-val-maxlength", errorMessage); - htmlAttributes.TryAdd("data-val-maxlength-max", maxla.Length.ToString(CultureInfo.InvariantCulture)); - break; - - case MinLengthAttribute minla: - htmlAttributes.TryAdd("data-val-minlength", errorMessage); - htmlAttributes.TryAdd("data-val-minlength-min", minla.Length.ToString(CultureInfo.InvariantCulture)); - break; - - case RangeAttribute ra: - // The JS range validator is numeric-only (uses Number()); skip non-numeric operands. - if (!IsNumericRangeOperand(ra.OperandType)) - { - break; - } - // Triggers RangeAttribute.SetupConversion() to convert string Min/Max to OperandType. - ra.IsValid(3); - htmlAttributes.TryAdd("data-val-range", errorMessage); - htmlAttributes.TryAdd("data-val-range-min", Convert.ToString(ra.Minimum, CultureInfo.InvariantCulture)!); - htmlAttributes.TryAdd("data-val-range-max", Convert.ToString(ra.Maximum, CultureInfo.InvariantCulture)!); - break; - - case RegularExpressionAttribute rea: - htmlAttributes.TryAdd("data-val-regex", errorMessage); - htmlAttributes.TryAdd("data-val-regex-pattern", rea.Pattern); - break; - - case CompareAttribute ca: - htmlAttributes.TryAdd("data-val-equalto", errorMessage); - // "*." prefix tells the JS equalto validator to resolve the other field - // relative to the current field's name prefix (e.g., "Model.Password"). - htmlAttributes.TryAdd("data-val-equalto-other", "*." + ca.OtherProperty); - break; - - case EmailAddressAttribute: - htmlAttributes.TryAdd("data-val-email", errorMessage); - break; - - case UrlAttribute: - htmlAttributes.TryAdd("data-val-url", errorMessage); - break; - - case PhoneAttribute: - htmlAttributes.TryAdd("data-val-phone", errorMessage); - break; - - case CreditCardAttribute: - htmlAttributes.TryAdd("data-val-creditcard", errorMessage); - break; - - case FileExtensionsAttribute fea: - htmlAttributes.TryAdd("data-val-fileextensions", errorMessage); - var normalizedExtensions = fea.Extensions - .Replace(" ", string.Empty) - .Replace(".", string.Empty) - .ToLowerInvariant(); - var parsedExtensions = normalizedExtensions - .Split(',') - .Select(e => "." + e); - htmlAttributes.TryAdd("data-val-fileextensions-extensions", string.Join(",", parsedExtensions)); - break; - - default: - if (validationAttribute is IClientValidationAdapter adapter) - { - foreach (var rule in adapter.GetClientValidationRules(errorMessage)) - { - EmitRule(htmlAttributes, rule); - } - } - break; - } - } - - // Flattens a ClientValidationRule into the data-val-* dictionary. Null parameter values - // are skipped; non-string values are formatted with invariant culture. - private static void EmitRule(Dictionary htmlAttributes, ClientValidationRule rule) - { - htmlAttributes.TryAdd($"data-val-{rule.Name}", rule.ErrorMessage); - foreach (var (paramName, paramValue) in rule.Parameters) - { - if (paramValue is null) - { - continue; - } - - var formatted = paramValue switch - { - string s => s, - bool b => b ? "true" : "false", - IFormattable f => f.ToString(format: null, CultureInfo.InvariantCulture), - _ => paramValue.ToString() ?? string.Empty, - }; - - htmlAttributes.TryAdd($"data-val-{rule.Name}-{paramName}", formatted); - } - } - - // RangeAttribute supports non-numeric operand types (e.g., DateTime) that the JS validator - // can't compare; only emit data-val-range for numeric ones. - private static bool IsNumericRangeOperand(Type operandType) - => operandType == typeof(int) - || operandType == typeof(long) - || operandType == typeof(short) - || operandType == typeof(byte) - || operandType == typeof(uint) - || operandType == typeof(ulong) - || operandType == typeof(ushort) - || operandType == typeof(sbyte) - || operandType == typeof(double) - || operandType == typeof(float) - || operandType == typeof(decimal); - - // Mirrors the decision tree used the server-side validation. - // Resource attribute bypasses the localizer (resource lookup is the canonical localized source). - // Literal acts as both lookup key and fallback for the localizer. - private string ResolveDisplayName(in FieldMetadata metadata, string fieldName) - { - if (metadata.ResourceDisplayAttribute is { } resourceAttribute) - { - return resourceAttribute.GetName() ?? fieldName; - } - - if (metadata.LiteralDisplayName is not { } literal) - { - return fieldName; - } - - if (_validationLocalizer is null) - { - return literal; - } - - return _validationLocalizer.ResolveDisplayName(new DisplayNameLocalizationContext - { - Type = metadata.DeclaringType, - DisplayName = literal, - MemberName = fieldName, - }) ?? literal; - } - - // Mirrors the decision tree used the server-side validation (see ResolveDisplayName). - private string ResolveErrorMessage( - ValidationAttribute attribute, - string fieldName, - string displayName, - Type? declaringType) - { - if (_validationLocalizer is null || attribute.ErrorMessageResourceType is not null) - { - return attribute.FormatErrorMessage(displayName); - } - - return _validationLocalizer.ResolveErrorMessage(new ErrorMessageLocalizationContext - { - MemberName = fieldName, - DisplayName = displayName, - DeclaringType = declaringType, - Attribute = attribute, - }) ?? attribute.FormatErrorMessage(displayName); - } - - // All fields are culture-independent; localized text is resolved per call. At most one of - // ResourceDisplayAttribute and LiteralDisplayName is non-null; both null means the property - // has no display attribute. - private readonly struct FieldMetadata( - ValidationAttribute[] validationAttributes, - Type? declaringType, - DisplayAttribute? resourceDisplayAttribute, - string? literalDisplayName) - { - // FieldMetadata.Empty is the sentinel for "no validatable property by this name". - public static readonly FieldMetadata Empty = new( - validationAttributes: [], - declaringType: null, - resourceDisplayAttribute: null, - literalDisplayName: null); - - public ValidationAttribute[] ValidationAttributes { get; } = validationAttributes; - - public Type? DeclaringType { get; } = declaringType; - - // [Display(Name=..., ResourceType=...)] - public DisplayAttribute? ResourceDisplayAttribute { get; } = resourceDisplayAttribute; - - // [Display(Name="X")] (no ResourceType) or [DisplayName("X")]. - public string? LiteralDisplayName { get; } = literalDisplayName; - } - - [UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "Model types are application code and are preserved by default.")] - private static FieldMetadata BuildMetadata(Type modelType, string fieldName) - { - var property = modelType.GetProperty(fieldName, BindingFlags.Public | BindingFlags.Instance); - if (property is null) - { - return FieldMetadata.Empty; - } - - var validationAttributes = property.GetCustomAttributes(inherit: true).ToArray(); - - var displayAttribute = property.GetCustomAttribute(inherit: true); - DisplayAttribute? resourceDisplayAttribute = null; - string? literalDisplayName = null; - - if (displayAttribute is { ResourceType: not null, Name: not null }) - { - resourceDisplayAttribute = displayAttribute; - } - else - { - literalDisplayName = displayAttribute?.Name - ?? property.GetCustomAttribute(inherit: true)?.DisplayName; - } - - return new FieldMetadata(validationAttributes, property.DeclaringType, resourceDisplayAttribute, literalDisplayName); - } -} diff --git a/src/Components/Forms/src/ClientValidation/IClientValidationAdapter.cs b/src/Components/Forms/src/ClientValidation/IClientValidationAdapter.cs deleted file mode 100644 index 0a0d52fe8886..000000000000 --- a/src/Components/Forms/src/ClientValidation/IClientValidationAdapter.cs +++ /dev/null @@ -1,21 +0,0 @@ -// 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.Forms.ClientValidation; - -/// -/// Implemented by subclasses -/// that support client-side validation by emitting data-val-* HTML attributes. -/// -public interface IClientValidationAdapter -{ - /// - /// Produces the client-side validation rules for this attribute. - /// Return an empty sequence if the attribute should not emit any client-side rule. - /// - /// - /// The pre-formatted (optionally localized) error message. - /// - IEnumerable GetClientValidationRules(string errorMessage); -} - diff --git a/src/Components/Forms/src/ClientValidation/IClientValidationService.cs b/src/Components/Forms/src/ClientValidation/IClientValidationService.cs deleted file mode 100644 index ef85d3a05d94..000000000000 --- a/src/Components/Forms/src/ClientValidation/IClientValidationService.cs +++ /dev/null @@ -1,17 +0,0 @@ -// 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.Forms.ClientValidation; - -/// -/// Provides client-side validation HTML attributes (data-val-*) for form fields -/// based on their s. -/// -public interface IClientValidationService -{ - /// - /// Gets the data-val-* HTML attributes for a form field. - /// Returns if no validation attributes apply. - /// - IReadOnlyDictionary? GetClientValidationAttributes(FieldIdentifier fieldIdentifier); -} diff --git a/src/Components/Forms/src/DataAnnotationsValidator.cs b/src/Components/Forms/src/DataAnnotationsValidator.cs index e02642c180d4..f86c17ab84de 100644 --- a/src/Components/Forms/src/DataAnnotationsValidator.cs +++ b/src/Components/Forms/src/DataAnnotationsValidator.cs @@ -1,14 +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 Microsoft.AspNetCore.Components.Forms.ClientValidation; - namespace Microsoft.AspNetCore.Components.Forms; /// /// Adds Data Annotations validation support to an . -/// When rendering in a static SSR context, also activates client-side validation -/// by storing an on the . /// public class DataAnnotationsValidator : ComponentBase, IDisposable { @@ -20,8 +16,10 @@ public class DataAnnotationsValidator : ComponentBase, IDisposable [Inject] private IServiceProvider ServiceProvider { get; set; } = default!; /// - /// Gets or sets whether client-side validation attributes (data-val-*) should be emitted - /// on input components within this form. Default is true. + /// Gets or sets whether client-side validation rules are emitted to the browser for this form. + /// When (the default), the framework includes the validation rules for + /// the form's model in the rendered HTML so user errors can be reported without a round trip + /// to the server. When , only server-side validation runs. /// [Parameter] public bool EnableClientValidation { get; set; } = true; @@ -38,14 +36,9 @@ protected override void OnInitialized() _subscriptions = CurrentEditContext.EnableDataAnnotationsValidation(ServiceProvider); _originalEditContext = CurrentEditContext; - // Enable client-side validation only in static SSR context. - // AssignedRenderMode is null when rendering statically (no interactive mode assigned). - if (EnableClientValidation && AssignedRenderMode is null) + if (EnableClientValidation) { - if (ServiceProvider.GetService(typeof(IClientValidationService)) is { } service) - { - CurrentEditContext.Properties[typeof(IClientValidationService)] = service; - } + CurrentEditContext.Properties[typeof(DataAnnotationsValidator)] = true; } } @@ -74,8 +67,7 @@ void IDisposable.Dispose() _subscriptions?.Dispose(); _subscriptions = null; - // Clean up the client validation service reference from EditContext - CurrentEditContext?.Properties.Remove(typeof(IClientValidationService)); + CurrentEditContext?.Properties.Remove(typeof(DataAnnotationsValidator)); Dispose(disposing: true); } diff --git a/src/Components/Forms/src/PublicAPI.Unshipped.txt b/src/Components/Forms/src/PublicAPI.Unshipped.txt index 665338094430..829e3a1e5e7a 100644 --- a/src/Components/Forms/src/PublicAPI.Unshipped.txt +++ b/src/Components/Forms/src/PublicAPI.Unshipped.txt @@ -2,20 +2,8 @@ Microsoft.AspNetCore.Components.Forms.ValidationRequestedEventArgs.AddAsyncValidator(System.Func! validator) -> void *REMOVED*static Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.AddDataAnnotationsValidation(this Microsoft.AspNetCore.Components.Forms.EditContext! editContext) -> Microsoft.AspNetCore.Components.Forms.EditContext! *REMOVED*static Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.EnableDataAnnotationsValidation(this Microsoft.AspNetCore.Components.Forms.EditContext! editContext) -> System.IDisposable! -Microsoft.AspNetCore.Components.Forms.ClientValidation.ClientValidationRule -Microsoft.AspNetCore.Components.Forms.ClientValidation.ClientValidationRule.ClientValidationRule(string! name, string! errorMessage) -> void -Microsoft.AspNetCore.Components.Forms.ClientValidation.ClientValidationRule.ErrorMessage.get -> string! -Microsoft.AspNetCore.Components.Forms.ClientValidation.ClientValidationRule.Name.get -> string! -Microsoft.AspNetCore.Components.Forms.ClientValidation.ClientValidationRule.Parameters.get -> System.Collections.Generic.IReadOnlyDictionary! -Microsoft.AspNetCore.Components.Forms.ClientValidation.ClientValidationRule.WithParameter(string! name, object? value) -> Microsoft.AspNetCore.Components.Forms.ClientValidation.ClientValidationRule! -Microsoft.AspNetCore.Components.Forms.ClientValidation.IClientValidationAdapter -Microsoft.AspNetCore.Components.Forms.ClientValidation.IClientValidationAdapter.GetClientValidationRules(string! errorMessage) -> System.Collections.Generic.IEnumerable! -Microsoft.AspNetCore.Components.Forms.ClientValidation.IClientValidationService -Microsoft.AspNetCore.Components.Forms.ClientValidation.IClientValidationService.GetClientValidationAttributes(Microsoft.AspNetCore.Components.Forms.FieldIdentifier fieldIdentifier) -> System.Collections.Generic.IReadOnlyDictionary? Microsoft.AspNetCore.Components.Forms.DataAnnotationsValidator.EnableClientValidation.get -> bool Microsoft.AspNetCore.Components.Forms.DataAnnotationsValidator.EnableClientValidation.set -> void -Microsoft.Extensions.DependencyInjection.ClientValidationServiceCollectionExtensions -static Microsoft.Extensions.DependencyInjection.ClientValidationServiceCollectionExtensions.AddClientValidation(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! Microsoft.AspNetCore.Components.Forms.EditContext.ValidateAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Components.Forms.EditContext.RegisterAsyncFieldValidator(in Microsoft.AspNetCore.Components.Forms.FieldIdentifier fieldIdentifier, System.Func! validator) -> void Microsoft.AspNetCore.Components.Forms.EditContext.IsValidationPending(in Microsoft.AspNetCore.Components.Forms.FieldIdentifier fieldIdentifier) -> bool diff --git a/src/Components/Forms/test/DefaultClientValidationServiceTest.cs b/src/Components/Forms/test/DefaultClientValidationServiceTest.cs deleted file mode 100644 index 1c06e5fea566..000000000000 --- a/src/Components/Forms/test/DefaultClientValidationServiceTest.cs +++ /dev/null @@ -1,738 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable enable - -using System.ComponentModel.DataAnnotations; -using System.Globalization; -using Microsoft.AspNetCore.Components.Forms.ClientValidation; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Validation; - -namespace Microsoft.AspNetCore.Components.Forms; - -public class DefaultClientValidationServiceTest -{ - private readonly DefaultClientValidationService _service = CreateService(); - - private static DefaultClientValidationService CreateService(IValidationLocalizer? localizer = null) - { - var services = new ServiceCollection(); - services.AddOptions().Configure(o => o.Localizer = localizer); - return new DefaultClientValidationService(services.BuildServiceProvider()); - } - - [Fact] - public void RequiredAttribute_GeneratesDataValRequired() - { - var attrs = GetAttributes(nameof(RequiredModel.Name)); - - Assert.NotNull(attrs); - Assert.Equal("true", attrs["data-val"]); - Assert.True(attrs.ContainsKey("data-val-required")); - } - - [Fact] - public void StringLengthAttribute_GeneratesDataValLength() - { - var attrs = GetAttributes(nameof(StringLengthModel.Name)); - - Assert.NotNull(attrs); - Assert.True(attrs.ContainsKey("data-val-length")); - Assert.Equal("2", attrs["data-val-length-min"]); - Assert.Equal("100", attrs["data-val-length-max"]); - } - - [Fact] - public void MaxLengthAttribute_GeneratesDataValMaxlength() - { - var attrs = GetAttributes(nameof(MaxLengthModel.Name)); - - Assert.NotNull(attrs); - Assert.True(attrs.ContainsKey("data-val-maxlength")); - Assert.Equal("50", attrs["data-val-maxlength-max"]); - } - - [Fact] - public void MinLengthAttribute_GeneratesDataValMinlength() - { - var attrs = GetAttributes(nameof(MinLengthModel.Name)); - - Assert.NotNull(attrs); - Assert.True(attrs.ContainsKey("data-val-minlength")); - Assert.Equal("3", attrs["data-val-minlength-min"]); - } - - [Fact] - public void RangeAttribute_GeneratesDataValRange() - { - var attrs = GetAttributes(nameof(RangeModel.Age)); - - Assert.NotNull(attrs); - Assert.True(attrs.ContainsKey("data-val-range")); - Assert.Equal("18", attrs["data-val-range-min"]); - Assert.Equal("120", attrs["data-val-range-max"]); - } - - [Fact] - public void RegularExpressionAttribute_GeneratesDataValRegex() - { - var attrs = GetAttributes(nameof(RegexModel.ZipCode)); - - Assert.NotNull(attrs); - Assert.True(attrs.ContainsKey("data-val-regex")); - Assert.Equal(@"\d{5}", attrs["data-val-regex-pattern"]); - } - - [Fact] - public void CompareAttribute_GeneratesDataValEqualto() - { - var attrs = GetAttributes(nameof(CompareModel.ConfirmPassword)); - - Assert.NotNull(attrs); - Assert.True(attrs.ContainsKey("data-val-equalto")); - Assert.Equal("*.Password", attrs["data-val-equalto-other"]); - } - - [Fact] - public void EmailAddressAttribute_GeneratesDataValEmail() - { - var attrs = GetAttributes(nameof(EmailModel.Email)); - - Assert.NotNull(attrs); - Assert.True(attrs.ContainsKey("data-val-email")); - } - - [Fact] - public void UrlAttribute_GeneratesDataValUrl() - { - var attrs = GetAttributes(nameof(UrlModel.Website)); - - Assert.NotNull(attrs); - Assert.True(attrs.ContainsKey("data-val-url")); - } - - [Fact] - public void PhoneAttribute_GeneratesDataValPhone() - { - var attrs = GetAttributes(nameof(PhoneModel.Phone)); - - Assert.NotNull(attrs); - Assert.True(attrs.ContainsKey("data-val-phone")); - } - - [Fact] - public void CreditCardAttribute_GeneratesDataValCreditcard() - { - var attrs = GetAttributes(nameof(CreditCardModel.Card)); - - Assert.NotNull(attrs); - Assert.True(attrs.ContainsKey("data-val-creditcard")); - } - - [Fact] - public void FileExtensionsAttribute_GeneratesDataValFileextensions() - { - var attrs = GetAttributes(nameof(FileExtensionsModel.Avatar)); - - Assert.NotNull(attrs); - Assert.True(attrs.ContainsKey("data-val-fileextensions")); - Assert.Equal(".png,.jpg,.jpeg,.gif", attrs["data-val-fileextensions-extensions"]); - } - - [Fact] - public void MultipleAttributes_AllGenerated() - { - var attrs = GetAttributes(nameof(MultipleAttrsModel.Email)); - - Assert.NotNull(attrs); - Assert.True(attrs.ContainsKey("data-val-required")); - Assert.True(attrs.ContainsKey("data-val-email")); - } - - [Fact] - public void PropertyWithNoValidationAttributes_ReturnsNull() - { - var attrs = GetAttributes(nameof(NoValidationModel.Name)); - - Assert.Null(attrs); - } - - [Fact] - public void NonExistentProperty_ReturnsNull() - { - var model = new NoValidationModel(); - var fieldId = new FieldIdentifier(model, "NonExistent"); - - var attrs = _service.GetClientValidationAttributes(fieldId); - - Assert.Null(attrs); - } - - [Fact] - public void DisplayAttribute_UsedInErrorMessage() - { - var attrs = GetAttributes(nameof(DisplayNameModel.Email)); - - Assert.NotNull(attrs); - var errorMessage = (string)attrs["data-val-required"]; - Assert.Contains("Email Address", errorMessage); - } - - [Fact] - public void CachesReflectionResults_NotRenderedDictionary() - { - // The cache stores reflection results (FieldMetadata) keyed by (Type, FieldName), not the - // rendered HTML attribute dictionary. Each call constructs a fresh dictionary so per-call - // localization respects the current request culture. Assert that two calls produce - // equivalent (but not the same instance) output for the same (Type, FieldName) pair. - var model1 = new RequiredModel(); - var model2 = new RequiredModel(); - var fieldId1 = new FieldIdentifier(model1, nameof(RequiredModel.Name)); - var fieldId2 = new FieldIdentifier(model2, nameof(RequiredModel.Name)); - - var attrs1 = _service.GetClientValidationAttributes(fieldId1); - var attrs2 = _service.GetClientValidationAttributes(fieldId2); - - Assert.NotNull(attrs1); - Assert.NotNull(attrs2); - Assert.NotSame(attrs1, attrs2); - Assert.Equal(attrs1.Count, attrs2.Count); - foreach (var (key, value) in attrs1) - { - Assert.Equal(value, attrs2[key]); - } - } - - [Fact] - public void CustomAdapter_GeneratesCustomAttributes() - { - var attrs = GetAttributes(nameof(CustomAdapterModel.Value)); - - Assert.NotNull(attrs); - Assert.Equal("true", attrs["data-val"]); - Assert.True(attrs.ContainsKey("data-val-custom")); - Assert.Equal("custom-value", attrs["data-val-custom-param"]); - } - - private IReadOnlyDictionary? GetAttributes(string propertyName) where TModel : new() - { - var model = new TModel(); - var fieldId = new FieldIdentifier(model, propertyName); - return _service.GetClientValidationAttributes(fieldId); - } - - // Test models - private class RequiredModel { [Required] public string Name { get; set; } = ""; } - private class StringLengthModel { [StringLength(100, MinimumLength = 2)] public string Name { get; set; } = ""; } - private class MaxLengthModel { [MaxLength(50)] public string Name { get; set; } = ""; } - private class MinLengthModel { [MinLength(3)] public string Name { get; set; } = ""; } - private class RangeModel { [Range(18, 120)] public int Age { get; set; } } - private class RegexModel { [RegularExpression(@"\d{5}")] public string ZipCode { get; set; } = ""; } - private class CompareModel { public string Password { get; set; } = ""; [Compare("Password")] public string ConfirmPassword { get; set; } = ""; } - private class EmailModel { [EmailAddress] public string Email { get; set; } = ""; } - private class UrlModel { [Url] public string Website { get; set; } = ""; } - private class PhoneModel { [Phone] public string Phone { get; set; } = ""; } - private class CreditCardModel { [CreditCard] public string Card { get; set; } = ""; } - private class FileExtensionsModel { [FileExtensions(Extensions = "png,jpg,jpeg,gif")] public string Avatar { get; set; } = ""; } - private class MultipleAttrsModel { [Required][EmailAddress] public string Email { get; set; } = ""; } - private class NoValidationModel { public string Name { get; set; } = ""; } - private class DisplayNameModel { [Required][Display(Name = "Email Address")] public string Email { get; set; } = ""; } - private class CustomAdapterModel { [CustomValidation] public string Value { get; set; } = ""; } - - private class CustomValidationAttribute : ValidationAttribute, IClientValidationAdapter - { - public IEnumerable GetClientValidationRules(string errorMessage) - => [new ClientValidationRule("custom", errorMessage).WithParameter("param", "custom-value")]; - } - - // Nested model tests - private class ParentModel - { - public AddressModel Address { get; set; } = new(); - } - - private class AddressModel - { - [Required] - [StringLength(200)] - public string Street { get; set; } = ""; - - [Required] - [RegularExpression(@"\d{5}")] - public string ZipCode { get; set; } = ""; - } - - [Fact] - public void NestedModel_GeneratesAttributesFromNestedType() - { - // FieldIdentifier for nested model: Model = parentModel.Address, FieldName = "Street" - var parent = new ParentModel(); - var fieldId = new FieldIdentifier(parent.Address, nameof(AddressModel.Street)); - - var attrs = _service.GetClientValidationAttributes(fieldId); - - Assert.NotNull(attrs); - Assert.Equal("true", attrs["data-val"]); - Assert.True(attrs.ContainsKey("data-val-required")); - Assert.True(attrs.ContainsKey("data-val-length")); - Assert.Equal("200", attrs["data-val-length-max"]); - } - - [Fact] - public void NestedModel_DifferentFieldsOnSameNestedType() - { - var parent = new ParentModel(); - var streetField = new FieldIdentifier(parent.Address, nameof(AddressModel.Street)); - var zipField = new FieldIdentifier(parent.Address, nameof(AddressModel.ZipCode)); - - var streetAttrs = _service.GetClientValidationAttributes(streetField); - var zipAttrs = _service.GetClientValidationAttributes(zipField); - - Assert.NotNull(streetAttrs); - Assert.NotNull(zipAttrs); - - // Street has StringLength, ZipCode has RegularExpression - Assert.True(streetAttrs.ContainsKey("data-val-length")); - Assert.False(streetAttrs.ContainsKey("data-val-regex")); - - Assert.True(zipAttrs.ContainsKey("data-val-regex")); - Assert.False(zipAttrs.ContainsKey("data-val-length")); - } - - [Fact] - public void DisplayNameAttribute_UsedInErrorMessage() - { - var attrs = GetAttributes(nameof(DisplayNameAttrModel.Name)); - - Assert.NotNull(attrs); - var errorMessage = (string)attrs["data-val-required"]; - Assert.Contains("Full Name", errorMessage); - } - - [Fact] - public void StringLength_MaxOnly_OmitsMinAttribute() - { - var attrs = GetAttributes(nameof(StringLengthMaxOnlyModel.Name)); - - Assert.NotNull(attrs); - Assert.True(attrs.ContainsKey("data-val-length")); - Assert.Equal("50", attrs["data-val-length-max"]); - Assert.False(attrs.ContainsKey("data-val-length-min")); - } - - [Fact] - public void Range_DoubleValues_FormatsWithInvariantCulture() - { - var attrs = GetAttributes(nameof(DoubleRangeModel.Score)); - - Assert.NotNull(attrs); - Assert.Equal("0.5", attrs["data-val-range-min"]); - Assert.Equal("99.9", attrs["data-val-range-max"]); - } - - [Fact] - public void Range_DateTimeOperand_DoesNotEmitDataValRange() - { - // Non-numeric range types (e.g., DateTime) have no client-side equivalent in the JS validator, - // so we skip emitting data-val-range-* for them. Server-side validation still applies. - var attrs = GetAttributes(nameof(DateTimeRangeModel.Date)); - - Assert.Null(attrs); - } - - [Fact] - public void PropertyWithNoDisplayAttribute_UsesPropertyNameInErrorMessage() - { - var attrs = GetAttributes(nameof(RequiredModel.Name)); - - Assert.NotNull(attrs); - var errorMessage = (string)attrs["data-val-required"]; - Assert.Contains("Name", errorMessage); - } - - [Fact] - public void InheritedValidationAttributes_AreIncluded() - { - var attrs = GetAttributes(nameof(DerivedModel.BaseProp)); - - Assert.NotNull(attrs); - Assert.True(attrs.ContainsKey("data-val-required")); - } - - private class DisplayNameAttrModel { [Required][System.ComponentModel.DisplayName("Full Name")] public string Name { get; set; } = ""; } - private class StringLengthMaxOnlyModel { [StringLength(50)] public string Name { get; set; } = ""; } - private class DoubleRangeModel { [Range(0.5, 99.9)] public double Score { get; set; } } - private class DateTimeRangeModel { [Range(typeof(DateTime), "2020-01-01", "2030-12-31")] public DateTime Date { get; set; } } - private class BaseModel { [Required] public string BaseProp { get; set; } = ""; } - private class DerivedModel : BaseModel { } - - [Fact] - public void Localizer_NotConfigured_ProducesSameOutputAsNoLocalizer() - { - // Regression guard: pin no-localizer baseline. - var noLocalizer = CreateService(); - var fieldId = new FieldIdentifier(new DisplayNameModel(), nameof(DisplayNameModel.Email)); - - var attrs = noLocalizer.GetClientValidationAttributes(fieldId); - - Assert.NotNull(attrs); - Assert.Equal("The Email Address field is required.", attrs["data-val-required"]); - } - - [Fact] - public void Localizer_Configured_DisplayNameIsLocalized_AndAppearsInErrorMessage() - { - var localizer = new RecordingValidationLocalizer - { - DisplayNameResults = { ["Email Address"] = "Adresse e-mail" }, - }; - var service = CreateService(localizer); - var fieldId = new FieldIdentifier(new DisplayNameModel(), nameof(DisplayNameModel.Email)); - - var attrs = service.GetClientValidationAttributes(fieldId); - - Assert.NotNull(attrs); - Assert.Contains("Adresse e-mail", (string)attrs["data-val-required"]); - } - - [Fact] - public void Localizer_Configured_DisplayNameLookupMisses_FallsBackToLiteral() - { - // Localizer returns null for the literal; expect the literal (not the member name) in the message. - var localizer = new RecordingValidationLocalizer(); - var service = CreateService(localizer); - var fieldId = new FieldIdentifier(new DisplayNameModel(), nameof(DisplayNameModel.Email)); - - var attrs = service.GetClientValidationAttributes(fieldId); - - Assert.NotNull(attrs); - Assert.Contains("Email Address", (string)attrs["data-val-required"]); - Assert.DoesNotContain("The Email field", (string)attrs["data-val-required"]); - } - - [Fact] - public void Localizer_Configured_NoDisplayAttribute_LocalizerNotCalledForDisplayName() - { - // No [Display] / [DisplayName] → LiteralDisplayName == null → ResolveDisplayName must - // skip the localizer entirely and fall back to the member name. - var localizer = new RecordingValidationLocalizer(); - var service = CreateService(localizer); - var fieldId = new FieldIdentifier(new RequiredModel(), nameof(RequiredModel.Name)); - - var attrs = service.GetClientValidationAttributes(fieldId); - - Assert.NotNull(attrs); - Assert.Empty(localizer.DisplayNameCalls); - Assert.Contains("Name", (string)attrs["data-val-required"]); - } - - [Fact] - public void Localizer_Configured_DisplayNameContext_HasCorrectFields() - { - var localizer = new RecordingValidationLocalizer(); - var service = CreateService(localizer); - var fieldId = new FieldIdentifier(new DisplayNameModel(), nameof(DisplayNameModel.Email)); - - service.GetClientValidationAttributes(fieldId); - - var displayCall = Assert.Single(localizer.DisplayNameCalls); - Assert.Equal("Email Address", displayCall.DisplayName); - Assert.Equal("Email", displayCall.MemberName); - Assert.Equal(typeof(DisplayNameModel), displayCall.Type); - } - - [Fact] - public void Localizer_Configured_DeclaringType_IsBaseClassForInheritedProperty() - { - // BaseProp is declared on BaseModel; FieldIdentifier carries a DerivedModel instance. - // The localizer must see typeof(BaseModel) as DeclaringType, matching MEV's - // per-type localizer scoping for inherited members. - var localizer = new RecordingValidationLocalizer(); - var service = CreateService(localizer); - var fieldId = new FieldIdentifier(new InheritedDisplayDerivedModel(), nameof(InheritedDisplayDerivedModel.BaseDisplayProp)); - - service.GetClientValidationAttributes(fieldId); - - var displayCall = Assert.Single(localizer.DisplayNameCalls); - Assert.Equal(typeof(InheritedDisplayBaseModel), displayCall.Type); - } - - [Fact] - public void Localizer_Configured_ErrorMessageIsLocalized() - { - var localizer = new RecordingValidationLocalizer - { - ErrorMessageResults = { ["RequiredKey"] = "Le champ {0} est requis" }, - }; - var service = CreateService(localizer); - var fieldId = new FieldIdentifier(new LocalizedRequiredModel(), nameof(LocalizedRequiredModel.Email)); - - var attrs = service.GetClientValidationAttributes(fieldId); - - Assert.NotNull(attrs); - Assert.Equal("Le champ {0} est requis", attrs["data-val-required"]); - } - - [Fact] - public void Localizer_Configured_ErrorMessageMisses_FallsBackToFormatErrorMessage() - { - var localizer = new RecordingValidationLocalizer(); - var service = CreateService(localizer); - var fieldId = new FieldIdentifier(new LocalizedRequiredModel(), nameof(LocalizedRequiredModel.Email)); - - var attrs = service.GetClientValidationAttributes(fieldId); - - Assert.NotNull(attrs); - Assert.Equal("RequiredKey", attrs["data-val-required"]); - } - - [Fact] - public void Localizer_Configured_ErrorMessageContext_HasCorrectFields() - { - var localizer = new RecordingValidationLocalizer - { - DisplayNameResults = { ["Email Address"] = "Adresse e-mail" }, - }; - var service = CreateService(localizer); - var fieldId = new FieldIdentifier(new DisplayNameModel(), nameof(DisplayNameModel.Email)); - - service.GetClientValidationAttributes(fieldId); - - var errorCall = Assert.Single(localizer.ErrorMessageCalls); - Assert.Equal("Email", errorCall.MemberName); - Assert.Equal("Adresse e-mail", errorCall.DisplayName); - Assert.Equal(typeof(DisplayNameModel), errorCall.DeclaringType); - Assert.IsType(errorCall.Attribute); - } - - [Fact] - public void ResourceTypeDisplayAttribute_BypassesLocalizer() - { - // [Display(Name="ResourceKey", ResourceType=typeof(TestResources))] must call - // DisplayAttribute.GetName() directly (resource lookup is canonical localized source). - var localizer = new ThrowingDisplayNameLocalizer(); - var service = CreateService(localizer); - var fieldId = new FieldIdentifier(new ResourceDisplayModel(), nameof(ResourceDisplayModel.Field)); - - var attrs = service.GetClientValidationAttributes(fieldId); - - Assert.NotNull(attrs); - Assert.Contains(TestResources.ResourceKey, (string)attrs["data-val-required"]); - } - - [Fact] - public void ErrorMessageResourceType_BypassesLocalizer() - { - // [Required(ErrorMessageResourceType=..., ErrorMessageResourceName=...)] must use the - // attribute's own FormatErrorMessage (DataAnnotations does the resource lookup). - var localizer = new ThrowingErrorMessageLocalizer(); - var service = CreateService(localizer); - var fieldId = new FieldIdentifier(new ResourceErrorModel(), nameof(ResourceErrorModel.Field)); - - var attrs = service.GetClientValidationAttributes(fieldId); - - Assert.NotNull(attrs); - Assert.Equal(string.Format(CultureInfo.CurrentCulture, TestResources.RequiredError, "Field"), attrs["data-val-required"]); - } - - [Fact] - public void Localizer_Configured_RangeAttribute_LocalizedTemplateIsUsedAsIs() - { - // The localizer is responsible for any {0}/{1}/{2} substitution (via - // DefaultValidationLocalizer + ValidationAttributeFormatterRegistry on the server side). - // DefaultClientValidationService just passes the localizer's result through. - var localizer = new RecordingValidationLocalizer - { - ErrorMessageResults = { ["RangeKey"] = "Age doit être entre 18 et 120" }, - }; - var service = CreateService(localizer); - var fieldId = new FieldIdentifier(new LocalizedRangeModel(), nameof(LocalizedRangeModel.Age)); - - var attrs = service.GetClientValidationAttributes(fieldId); - - Assert.NotNull(attrs); - Assert.Equal("Age doit être entre 18 et 120", attrs["data-val-range"]); - // Min/Max parameters are still emitted with invariant culture from the attribute. - Assert.Equal("18", attrs["data-val-range-min"]); - Assert.Equal("120", attrs["data-val-range-max"]); - } - - [Fact] - public void Cache_DifferentCulturesProduceDifferentOutput() - { - // Regression guard for the original culture-poisoning bug. The reflection cache stores - // ValidationAttribute[], but the rendered data-val-* values are computed per call so - // different cultures produce different output. - var localizer = new CultureBasedLocalizer(); - var service = CreateService(localizer); - var fieldId = new FieldIdentifier(new LocalizedRequiredModel(), nameof(LocalizedRequiredModel.Email)); - - var originalCulture = CultureInfo.CurrentUICulture; - try - { - CultureInfo.CurrentUICulture = new CultureInfo("fr"); - var frAttrs = service.GetClientValidationAttributes(fieldId); - - CultureInfo.CurrentUICulture = new CultureInfo("de"); - var deAttrs = service.GetClientValidationAttributes(fieldId); - - Assert.NotNull(frAttrs); - Assert.NotNull(deAttrs); - Assert.Equal("Le champ Email est requis (fr)", frAttrs["data-val-required"]); - Assert.Equal("Das Feld Email ist erforderlich (de)", deAttrs["data-val-required"]); - } - finally - { - CultureInfo.CurrentUICulture = originalCulture; - } - } - - [Fact] - public void CustomAdapter_ReceivesLocalizedErrorMessage() - { - var localizer = new RecordingValidationLocalizer - { - ErrorMessageResults = { ["CustomKey"] = "Localized custom message" }, - }; - var service = CreateService(localizer); - var fieldId = new FieldIdentifier(new LocalizedCustomAdapterModel(), nameof(LocalizedCustomAdapterModel.Value)); - - var attrs = service.GetClientValidationAttributes(fieldId); - - Assert.NotNull(attrs); - Assert.Equal("Localized custom message", attrs["data-val-custom"]); - } - - [Fact] - public void ResourceTypeDisplayAttribute_ResolvesLocalizedDisplayNameInErrorMessage() - { - // No localizer configured. The [Display(ResourceType=..., Name=...)] strategy resolves - // the display name itself via DisplayAttribute.GetName(), and that value flows into - // attribute.FormatErrorMessage(). - var fieldId = new FieldIdentifier(new ResourceDisplayModel(), nameof(ResourceDisplayModel.Field)); - - var attrs = _service.GetClientValidationAttributes(fieldId); - - Assert.NotNull(attrs); - Assert.Contains(TestResources.ResourceKey, (string)attrs["data-val-required"]); - } - - [Fact] - public void NoDisplayAttribute_NoLocalizer_UsesMemberNameInErrorMessage() - { - // Sanity check: without [Display] and without a localizer, the message uses MemberName. - var fieldId = new FieldIdentifier(new RequiredModel(), nameof(RequiredModel.Name)); - - var attrs = _service.GetClientValidationAttributes(fieldId); - - Assert.NotNull(attrs); - Assert.Equal("The Name field is required.", attrs["data-val-required"]); - } - - // ---- Test doubles & models for the localization tests ---- - - private sealed class RecordingValidationLocalizer : IValidationLocalizer - { - public List DisplayNameCalls { get; } = new(); - public List ErrorMessageCalls { get; } = new(); - public Dictionary DisplayNameResults { get; } = new(); - public Dictionary ErrorMessageResults { get; } = new(); - - public string? ResolveDisplayName(in DisplayNameLocalizationContext context) - { - DisplayNameCalls.Add(context); - return context.DisplayName is not null && DisplayNameResults.TryGetValue(context.DisplayName, out var v) ? v : null; - } - - public string? ResolveErrorMessage(in ErrorMessageLocalizationContext context) - { - ErrorMessageCalls.Add(context); - var key = context.Attribute.ErrorMessage ?? context.Attribute.GetType().Name; - return ErrorMessageResults.TryGetValue(key, out var v) ? v : null; - } - } - - private sealed class ThrowingDisplayNameLocalizer : IValidationLocalizer - { - public string? ResolveDisplayName(in DisplayNameLocalizationContext context) - => throw new InvalidOperationException("ResolveDisplayName must not be called for the resource-attribute path."); - - public string? ResolveErrorMessage(in ErrorMessageLocalizationContext context) => null; - } - - private sealed class ThrowingErrorMessageLocalizer : IValidationLocalizer - { - public string? ResolveDisplayName(in DisplayNameLocalizationContext context) => null; - - public string? ResolveErrorMessage(in ErrorMessageLocalizationContext context) - => throw new InvalidOperationException("ResolveErrorMessage must not be called when ErrorMessageResourceType is set."); - } - - private sealed class CultureBasedLocalizer : IValidationLocalizer - { - public string? ResolveDisplayName(in DisplayNameLocalizationContext context) => null; - - public string? ResolveErrorMessage(in ErrorMessageLocalizationContext context) - => CultureInfo.CurrentUICulture.TwoLetterISOLanguageName switch - { - "fr" => $"Le champ {context.DisplayName} est requis (fr)", - "de" => $"Das Feld {context.DisplayName} ist erforderlich (de)", - _ => $"The {context.DisplayName} field is required (en)", - }; - } - - private class LocalizedRequiredModel - { - [Required(ErrorMessage = "RequiredKey")] - public string Email { get; set; } = ""; - } - - private class LocalizedRangeModel - { - [Range(18, 120, ErrorMessage = "RangeKey")] - public int Age { get; set; } - } - - private class LocalizedCustomAdapterModel - { - [LocalizedCustomValidation(ErrorMessage = "CustomKey")] - public string Value { get; set; } = ""; - } - - private sealed class LocalizedCustomValidationAttribute : ValidationAttribute, IClientValidationAdapter - { - public IEnumerable GetClientValidationRules(string errorMessage) - => [new ClientValidationRule("custom", errorMessage)]; - } - - private class ResourceDisplayModel - { - [Required] - [Display(Name = nameof(TestResources.ResourceKey), ResourceType = typeof(TestResources))] - public string Field { get; set; } = ""; - } - - private class ResourceErrorModel - { - [Required(ErrorMessageResourceType = typeof(TestResources), ErrorMessageResourceName = nameof(TestResources.RequiredError))] - public string Field { get; set; } = ""; - } - - private class InheritedDisplayBaseModel - { - [Required] - [Display(Name = "Base Display")] - public string BaseDisplayProp { get; set; } = ""; - } - - private class InheritedDisplayDerivedModel : InheritedDisplayBaseModel - { - } - - public static class TestResources - { - public static string ResourceKey => "Localized Display"; - public static string RequiredError => "The {0} field is REQUIRED (resourced)"; - } -} diff --git a/src/Components/Samples/BlazorUnitedApp/Data/FeedbackModel.cs b/src/Components/Samples/BlazorUnitedApp/Data/FeedbackModel.cs new file mode 100644 index 000000000000..6e943b54243e --- /dev/null +++ b/src/Components/Samples/BlazorUnitedApp/Data/FeedbackModel.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 System.ComponentModel.DataAnnotations; + +namespace BlazorUnitedApp.Data; + +// A second, deliberately different model so navigating between the two client-validation form +// pages with enhanced navigation exercises the JS reconcile (a reused carrier whose payload +// changes must drop the old form's rules and register the new ones). +public class FeedbackModel +{ + [Required(ErrorMessage = "Subject is required.")] + [StringLength(50, ErrorMessage = "Subject must be at most 50 characters.")] + public string Subject { get; set; } = string.Empty; + + [Required(ErrorMessage = "Message is required.")] + [RegularExpression(@"^[\w\s.,!?]+$", ErrorMessage = "Message may only contain letters, numbers, spaces and . , ! ? characters.")] + public string Message { get; set; } = string.Empty; + + [Range(1, 5, ErrorMessage = "Rating must be between 1 and 5.")] + public int Rating { get; set; } +} diff --git a/src/Components/Samples/BlazorUnitedApp/Data/RegistrationModel.cs b/src/Components/Samples/BlazorUnitedApp/Data/RegistrationModel.cs new file mode 100644 index 000000000000..26d7214671f8 --- /dev/null +++ b/src/Components/Samples/BlazorUnitedApp/Data/RegistrationModel.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel.DataAnnotations; + +namespace BlazorUnitedApp.Data; + +// Exercises a broad range of built-in DataAnnotations so the client-side validation rework can be +// tested by hand: required, length, email, phone, range, credit card, url, regex and compare all +// map to a corresponding JS validator. +public class RegistrationModel +{ + [Required(ErrorMessage = "Name is required.")] + [StringLength(20, MinimumLength = 2, ErrorMessage = "Name must be between 2 and 20 characters.")] + public string Name { get; set; } = string.Empty; + + [Required(ErrorMessage = "Email is required.")] + [EmailAddress(ErrorMessage = "Enter a valid email address.")] + public string Email { get; set; } = string.Empty; + + [Required(ErrorMessage = "Phone is required.")] + [Phone(ErrorMessage = "Enter a valid phone number.")] + public string Phone { get; set; } = string.Empty; + + [Range(18, 120, ErrorMessage = "Age must be between 18 and 120.")] + public int Age { get; set; } + + [Required(ErrorMessage = "Credit card is required.")] + [CreditCard(ErrorMessage = "Enter a valid credit card number.")] + public string CreditCard { get; set; } = string.Empty; + + [Url(ErrorMessage = "Enter a valid URL.")] + public string Website { get; set; } = string.Empty; + + [Required(ErrorMessage = "Password is required.")] + [StringLength(100, MinimumLength = 6, ErrorMessage = "Password must be at least 6 characters.")] + public string Password { get; set; } = string.Empty; + + [Compare(nameof(Password), ErrorMessage = "Passwords do not match.")] + public string ConfirmPassword { get; set; } = string.Empty; +} diff --git a/src/Components/Samples/BlazorUnitedApp/Pages/ClientValidation.razor b/src/Components/Samples/BlazorUnitedApp/Pages/ClientValidation.razor new file mode 100644 index 000000000000..58a2bdecdee3 --- /dev/null +++ b/src/Components/Samples/BlazorUnitedApp/Pages/ClientValidation.razor @@ -0,0 +1,92 @@ +@page "/client-validation" +@using BlazorUnitedApp.Data + +Client validation + +

Client-side validation

+ +

+ This page is rendered with static SSR and reached via enhanced + navigation. The form uses DataAnnotationsValidator, which activates + client-side validation: invalid input is reported in the browser without a round trip, and the + form only posts to the server once every rule passes. +

+

+ Things to try: submit empty to see all messages appear client-side; fix fields one by one and + watch messages clear as you type; then use the nav links to leave and come back (no-form to form) + or switch to the Feedback form (form to a different + form) to exercise the enhanced-navigation reconcile. +

+ + + + + +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ + +
+ +@if (_submitted) +{ + +} + +@code { + [SupplyParameterFromForm] + private RegistrationModel? Input { get; set; } + + private bool _submitted; + + protected override void OnInitialized() => Input ??= new(); + + private void HandleValidSubmit() => _submitted = true; +} diff --git a/src/Components/Samples/BlazorUnitedApp/Pages/ClientValidationFeedback.razor b/src/Components/Samples/BlazorUnitedApp/Pages/ClientValidationFeedback.razor new file mode 100644 index 000000000000..72f38440fdb3 --- /dev/null +++ b/src/Components/Samples/BlazorUnitedApp/Pages/ClientValidationFeedback.razor @@ -0,0 +1,56 @@ +@page "/client-validation-feedback" +@using BlazorUnitedApp.Data + +Client validation - feedback + +

Client-side validation - feedback

+ +

+ A second static-SSR form with a different set of rules. Navigating here from the + Registration form with enhanced navigation reuses the same + <blazor-client-validation-data> carrier element with a new payload, so the JS + engine must drop the registration form's rules and register these instead. +

+ + + + + +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ + +
+ +@if (_submitted) +{ + +} + +@code { + [SupplyParameterFromForm] + private FeedbackModel? Input { get; set; } + + private bool _submitted; + + protected override void OnInitialized() => Input ??= new(); + + private void HandleValidSubmit() => _submitted = true; +} diff --git a/src/Components/Web.JS/src/Boot.Web.ts b/src/Components/Web.JS/src/Boot.Web.ts index 8782c9a3eb79..8ab7a3016a0c 100644 --- a/src/Components/Web.JS/src/Boot.Web.ts +++ b/src/Components/Web.JS/src/Boot.Web.ts @@ -29,7 +29,7 @@ import { resolveOptions, CircuitStartOptions, ReconnectionOptions } from './Plat import { JSInitializer } from './JSInitializers/JSInitializers'; import { enableFocusOnNavigate } from './Rendering/FocusOnNavigate'; import { WebAssemblyStartOptions } from './Platform/WebAssemblyStartOptions'; -import { createValidationService, ValidationOptions } from './Validation'; +import { createBlazorValidation, ensureNovalidateOnForms, ValidationOptions } from './Validation'; let started = false; let rootComponentManager: WebRootComponentManager; @@ -79,15 +79,11 @@ function boot(options?: Partial) : Promise { enableFocusOnNavigate(jsEventRegistry); - // Client-side validation is initialized on demand: only when the page contains - // SSR-rendered form fields with data-val attributes. This avoids adding document-level - // event listeners in interactive-only apps that never use client-side validation. + // Client-side validation is initialized only when the page contains the + // SSR-rendered custom element bearing the client validation data. + // This avoids adding event listeners in interactive-only apps that never use client validation. jsEventRegistry.addEventListener('enhancedload', () => { - if (Blazor.formValidation) { - Blazor.formValidation.scanRules(); - } else { - initFormValidationIfNeeded(options?.ssr?.formValidation); - } + initFormValidationIfNeeded(options?.ssr?.formValidation); }); // Wait until the initial page response completes before activating interactive components. @@ -178,16 +174,15 @@ function onInitialDomContentLoaded(options: Partial) { callAfterStartedCallbacks(initializersPromise); } -function initFormValidationIfNeeded(formValidation?: ValidationOptions): void { +function initFormValidationIfNeeded(validationOptions?: ValidationOptions): void { if (Blazor.formValidation) { + // The service already exists. An enhanced-navigation morph reuses forms in place and strips the + // JS-added novalidate, so re-add it. + ensureNovalidateOnForms(); return; } - for (const form of Array.from(document.forms)) { - if (form.querySelector('[data-val="true"]')) { - Blazor.formValidation = createValidationService(formValidation); - return; - } - } + + Blazor.formValidation = createBlazorValidation(validationOptions); } async function resolveConfiguredOptions(initializers: Promise, options: TOptions): Promise { diff --git a/src/Components/Web.JS/src/Validation/Adapters/BlazorAdapter.ts b/src/Components/Web.JS/src/Validation/Adapters/BlazorAdapter.ts new file mode 100644 index 000000000000..12a7f4e7cbda --- /dev/null +++ b/src/Components/Web.JS/src/Validation/Adapters/BlazorAdapter.ts @@ -0,0 +1,209 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +import { registerCoreValidators } from '../CoreValidators'; +import { ErrorDisplay } from '../ErrorDisplay'; +import { EventManager } from '../EventManager'; +import { ElementState, ValidationEngine } from '../ValidationEngine'; +import { ValidatableElement, ValidationOptions, ValidationService, Validator, ValidatorRegistry } from '../ValidationTypes'; + +const ClientValidationElementName = 'blazor-client-validation-data'; + +// The attribute on the carrier element that holds the serialized validation rules. The payload +// lives in an attribute (not text content) so the carrier element renders nothing - no CSS or +// hidden attribute is needed to keep it out of the visible page. +const ClientValidationRulesAttributeName = 'data-rules'; + +interface ClientValidationFormDescriptor { + fields: ClientValidationFieldDescriptor[]; +} + +interface ClientValidationFieldDescriptor { + name: string; + rules: ClientValidationRule[]; +} + +interface ClientValidationRule { + name: string; + message: string; + params?: Record; +} + +/** + * Creates the client-side validation service when the page contains the known carrier element, + * or returns `undefined` otherwise. + * Registers built-in validators, defines the `` custom element that + * ingests the SSR-rendered rules, and attaches the form event interceptors. + */ +export function createBlazorValidation(options?: ValidationOptions): ValidationService | undefined { + if (!document.querySelector(ClientValidationElementName)) { + return undefined; + } + + const registry = new ValidatorRegistry(); + registerCoreValidators(registry); + + const errorDisplay = new ErrorDisplay(options?.cssClasses); + const engine = new ValidationEngine(registry, errorDisplay); + const eventManager = new EventManager(engine); + + eventManager.attachFormInterceptors(); + + // Upgrade any carrier instances already parsed before the element was defined, which fires their + // connectedCallback retroactively. + defineBlazorClientValidationDataElement(engine, eventManager); + customElements.upgrade(document); + + return { + addValidator: (name: string, validator: Validator) => registry.set(name, validator), + validateField: (element: ValidatableElement) => engine.validateElement(element), + validateForm: (form: HTMLFormElement) => engine.validateForm(form).size === 0, + }; +} + +/** + * Restores `novalidate` on every carrier-owning form after an enhanced navigation update. + * The morph strips the JS-added `novalidate` when it reuses a form, even when the rules are unchanged. + * Rule changes and carrier add/remove are handled by the element's own callbacks, so this only + * restores `novalidate`. + */ +export function ensureNovalidateOnForms(): void { + document.querySelectorAll(ClientValidationElementName).forEach(element => { + (element as { ensureNovalidate?: () => void }).ensureNovalidate?.(); + }); +} + +function defineBlazorClientValidationDataElement( + engine: ValidationEngine, + eventManager: EventManager, +): void { + if (customElements.get(ClientValidationElementName)) { + return; + } + + class BlazorClientValidationDataElement extends HTMLElement { + static formAssociated = true; + + static observedAttributes = [ClientValidationRulesAttributeName]; + + private internals: ElementInternals; + + private registeredInputs: ValidatableElement[] = []; + + constructor() { + super(); + this.internals = this.attachInternals(); + } + + connectedCallback(): void { + this.applyRules(); + } + + disconnectedCallback(): void { + this.teardown(); + } + + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void { + if (name === ClientValidationRulesAttributeName + && this.isConnected + && oldValue !== null + && oldValue !== newValue) { + this.applyRules(); + } + } + + // Suppress the browser's native validation UI so the engine's capture-phase submit handling + // is not pre-empted. + ensureNovalidate(): void { + const form = this.internals.form; + if (form && !form.hasAttribute('novalidate')) { + form.setAttribute('novalidate', ''); + } + } + + private applyRules(): void { + const form = this.internals.form; + + if (!form) { + return; + } + + this.teardown(); + const payload = this.getAttribute(ClientValidationRulesAttributeName) || ''; + this.registeredInputs = this.registerValidationData(form, payload); + } + + private teardown(): void { + for (const input of this.registeredInputs) { + engine.unregisterElement(input); + } + + this.registeredInputs = []; + } + + /** + * Parses a `` payload and registers each described field's input + * with the validation engine, attaching its event listeners. + * Inputs not found in the form, or already registered, are skipped. + */ + private registerValidationData( + form: HTMLFormElement, + payloadText: string, + ): ValidatableElement[] { + let formDescriptor: ClientValidationFormDescriptor | null = null; + + try { + formDescriptor = JSON.parse(payloadText || '{}'); + } catch (error) { + console.warn('Failed to parse client validation data:', error); + return []; + } + + if (!formDescriptor || !Array.isArray(formDescriptor.fields)) { + console.warn('Invalid client validation data format.'); + return []; + } + + this.ensureNovalidate(); + + const registeredInputs: ValidatableElement[] = []; + + for (const field of formDescriptor.fields) { + const input = form.querySelector('[name="' + CSS.escape(field.name) + '"]'); + + if (!input) { + // Skip input registration if the target input element is not found in the form. + continue; + } + + if (engine.getElementState(input)) { + // Avoid double input registration if connectedCallback runs multiple times. + continue; + } + + const rules = field.rules.map(rule => ({ + ruleName: rule.name, + errorMessage: rule.message, + params: rule.params || {}, + })); + + const state: ElementState = { + rules: rules, + form: form, + triggerEvents: 'default', + listenerController: new AbortController(), + hasBeenInvalid: false, + }; + + engine.registerElement(input, form, state); + eventManager.attachInputListeners(input); + registeredInputs.push(input); + } + + return registeredInputs; + } + } + + customElements.define(ClientValidationElementName, BlazorClientValidationDataElement); +} + diff --git a/src/Components/Web.JS/src/Validation/CoreValidators.ts b/src/Components/Web.JS/src/Validation/CoreValidators.ts index bba8b84f1702..dbc7aff48730 100644 --- a/src/Components/Web.JS/src/Validation/CoreValidators.ts +++ b/src/Components/Web.JS/src/Validation/CoreValidators.ts @@ -13,7 +13,7 @@ import { creditCardValidator } from './Validators/CreditCard'; import { equalToValidator } from './Validators/EqualTo'; import { fileExtensionsValidator } from './Validators/FileExtensions'; -/** Registers all built-in validators for standard data-val-* rules. */ +/** Registers built-in validators for supported DataAnnotations attributes. */ export function registerCoreValidators(registry: ValidatorRegistry): void { registry.set('required', requiredValidator); registry.set('length', stringLengthValidator); diff --git a/src/Components/Web.JS/src/Validation/DomScanner.ts b/src/Components/Web.JS/src/Validation/DomScanner.ts deleted file mode 100644 index 5746efe957c3..000000000000 --- a/src/Components/Web.JS/src/Validation/DomScanner.ts +++ /dev/null @@ -1,203 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -import { EventManager } from './EventManager'; -import { getElementForm, shouldSkipElement } from './DomUtils'; -import { ElementState, validatableElementSelector, ValidationEngine, ValidationRule } from './ValidationEngine'; -import { ValidatableElement } from './ValidationTypes'; - -/** - * Discovers validatable elements in the DOM and registers them with the engine. - * Handles both initial page scan and re-scans after DOM mutations (e.g., Blazor - * enhanced navigation). Uses fingerprinting to detect attribute changes without - * unnecessarily re-registering unchanged elements. - */ -export class DomScanner { - constructor( - private engine: ValidationEngine, - private eventManager: EventManager - ) { } - - /** Scans the given root (or entire document) for validatable elements. */ - scan(elementOrSelector?: ParentNode | string): void { - if (!elementOrSelector) { - this.scanSubtree(document); - } else { - const root = typeof elementOrSelector === 'string' - ? document.querySelector(elementOrSelector) - : elementOrSelector; - if (root) { - this.scanSubtree(root); - } - } - } - - private scanSubtree(root: ParentNode): void { - // Phase 1: Reconcile - clean up elements that are no longer valid candidates - this.reconcile(root); - - // Phase 2: Discover — find and register new/changed elements - const candidates = Array.from(root.querySelectorAll(validatableElementSelector)); - - for (const element of candidates) { - if (shouldSkipElement(element)) { - continue; - } - - // Only track one radio button per name group — the validator checks all siblings - if (element instanceof HTMLInputElement && element.type === 'radio') { - const form = getElementForm(element); - if (form && this.isRadioGroupAlreadyTracked(form, element.name)) { - continue; - } - } - - const previousState = this.engine.getElementState(element); - const fingerprint = computeElementFingerprint(element); - - if (previousState) { - // Element is already tracked. - // Check if its attributes have changed. - if (fingerprint === previousState.fingerprint) { - continue; - } - - // Unregister the element so we can re-register with the current state. - this.engine.unregisterElement(element); - } - - const rules = parseRules(element); - if (rules.length === 0) { - continue; - } - - const form = getElementForm(element); - if (!form) { - continue; - } - - const state: ElementState = { - rules: rules, - form: form, - triggerEvents: element.getAttribute('data-valevent')?.trim() || 'default', - listenerController: new AbortController(), - fingerprint: fingerprint, - hasBeenInvalid: false, - }; - - this.engine.registerElement(element, form, state); - this.eventManager.attachInputListeners(element); - - // Suppress native browser validation UI, we handle it ourselves - if (!form.hasAttribute('novalidate')) { - form.setAttribute('novalidate', ''); - } - } - } - - private reconcile(root: ParentNode): void { - for (const [form, formState] of this.engine.getTrackedForms()) { - if (!root.contains(form)) { - continue; - } - - if (!form.isConnected) { - this.unregisterElements(formState.trackedElements); - continue; - } - - this.unregisterElements(formState.trackedElements, element => - !element.isConnected || - shouldSkipElement(element) || - element.getAttribute('data-val') !== 'true'); - } - } - - private unregisterElements( - elements: Set, - predicate?: (el: ValidatableElement) => boolean, - ): void { - const toRemove = predicate ? [...elements].filter(predicate) : [...elements]; - for (const element of toRemove) { - this.engine.unregisterElement(element); - } - } - - private isRadioGroupAlreadyTracked(form: HTMLFormElement, groupName: string): boolean { - const formState = this.engine.getFormState(form); - if (!formState) { - return false; - } - - for (const tracked of formState.trackedElements) { - if (tracked instanceof HTMLInputElement - && tracked.type === 'radio' - && tracked.name === groupName) { - return true; - } - } - - return false; - } -} - -const ruleAttributePrefix = 'data-val-'; -const ruleAttributePrefixLength = ruleAttributePrefix.length; - -/** - * Parses data-val-* attributes on an element into structured validation rules. - * e.g., data-val-range="Error." + data-val-range-min="10" + data-val-range-max="50" - * becomes { ruleName: 'range', errorMessage: 'Error.', params: { min: '10', max: '50' } }. - */ -export function parseRules(element: ValidatableElement): ValidationRule[] { - const ruleMap: Record = {}; - - for (let i = 0; i < element.attributes.length; i++) { - const attr = element.attributes[i]; - if (!attr.name.startsWith(ruleAttributePrefix)) { - continue; - } - - const ruleAndParamName = attr.name.substring(ruleAttributePrefixLength); - const dashIndex = ruleAndParamName.indexOf('-'); - const ruleName = dashIndex === -1 ? ruleAndParamName : ruleAndParamName.substring(0, dashIndex); - const paramName = dashIndex === -1 ? undefined : ruleAndParamName.substring(dashIndex + 1); - - if (!ruleName) { - continue; - } - - const rule = ruleMap[ruleName] ?? { ruleName, errorMessage: '', params: {} }; - - if (!paramName) { - // Attribute shape: data-val-range="Value must be between 10 and 50." - rule.errorMessage = attr.value; - } else { - // Attribute shape: data-val-range-min="10" - rule.params[paramName] = attr.value; - } - - ruleMap[ruleName] = rule; - } - - return Object.values(ruleMap); -} - -function computeElementFingerprint(element: ValidatableElement): string { - const parts: string[] = []; - - const name = element.getAttribute('name'); - if (name) { - parts.push(`name=${name}`); - } - - for (let i = 0; i < element.attributes.length; i++) { - const attr = element.attributes[i]; - if (attr.name.startsWith('data-val')) { - parts.push(`${attr.name}=${attr.value}`); - } - } - - parts.sort(); - return parts.join('|'); -} diff --git a/src/Components/Web.JS/src/Validation/ErrorDisplay.ts b/src/Components/Web.JS/src/Validation/ErrorDisplay.ts index 2f9a12864de2..9067b88cdc5e 100644 --- a/src/Components/Web.JS/src/Validation/ErrorDisplay.ts +++ b/src/Components/Web.JS/src/Validation/ErrorDisplay.ts @@ -150,10 +150,13 @@ export class ErrorDisplay { summaryElement.appendChild(ul); } - // Add non-duplicate error messages to the summary. + // Add non-duplicate error messages to the summary. Mirror the server-rendered + // ValidationSummary markup (
  • ) so client- and + // server-produced summaries are styled identically. const uniqueErrorMessages = new Set(errors.values()); for (const errorMessage of uniqueErrorMessages) { const li = document.createElement('li'); + li.className = 'validation-message'; li.textContent = errorMessage; ul.appendChild(li); } diff --git a/src/Components/Web.JS/src/Validation/EventManager.ts b/src/Components/Web.JS/src/Validation/EventManager.ts index 325b64a1c924..419fce3c86bc 100644 --- a/src/Components/Web.JS/src/Validation/EventManager.ts +++ b/src/Components/Web.JS/src/Validation/EventManager.ts @@ -43,6 +43,8 @@ export class EventManager { return; } + // In order to support radio buttons, we need to attach listeners to all radio buttons in the group. + const targets = getListenerTargets(element); const signal = state.listenerController.signal; const validate = () => { @@ -50,11 +52,13 @@ export class EventManager { this.engine.updateValidationSummary(form); }; - // Explicit data-valevent override: listen to the specified event(s), no gating. + // Explicit triggerEvents override: listen to the specified event(s), no gating. if (state.triggerEvents !== 'default') { for (const eventType of state.triggerEvents.split(/\s+/)) { if (eventType) { - element.addEventListener(eventType, validate, { signal }); + for (const t of targets) { + t.addEventListener(eventType, validate, { signal }); + } } } return; @@ -69,8 +73,10 @@ export class EventManager { } }; - element.addEventListener('change', validate, { signal }); - element.addEventListener('input', validateGated, { signal }); + for (const t of targets) { + t.addEventListener('change', validate, { signal }); + t.addEventListener('input', validateGated, { signal }); + } } /** Attaches document-level submit and reset interceptors (capture phase). */ @@ -145,3 +151,13 @@ function dispatchValidationComplete(form: HTMLFormElement, valid: boolean, error detail: { valid, errors }, })); } + +function getListenerTargets(element: ValidatableElement): ValidatableElement[] { + if (element instanceof HTMLInputElement && element.type === 'radio' && element.name) { + const form = element.closest('form'); + if (form) { + return Array.from(form.querySelectorAll(`input[type="radio"][name="${CSS.escape(element.name)}"]`)); + } + } + return [element]; +} diff --git a/src/Components/Web.JS/src/Validation/ValidationEngine.ts b/src/Components/Web.JS/src/Validation/ValidationEngine.ts index bb37316ed5fe..04dbe8a5d92a 100644 --- a/src/Components/Web.JS/src/Validation/ValidationEngine.ts +++ b/src/Components/Web.JS/src/Validation/ValidationEngine.ts @@ -5,7 +5,7 @@ import { ErrorDisplay } from './ErrorDisplay'; import { findMessageElements, shouldSkipElement } from './DomUtils'; import { ValidatableElement, ValidationContext, ValidationResult, ValidatorRegistry } from './ValidationTypes'; -/** A parsed validation rule from data-val-* attributes on an element. */ +/** A validation rule definition. */ export type ValidationRule = { ruleName: string; errorMessage: string; @@ -17,7 +17,6 @@ export interface ElementState { rules: ValidationRule[]; form: HTMLFormElement; // Owning form, stored at registration to avoid DOM traversal on disconnect triggerEvents: string; // 'default' | 'submit' | space-separated event types - fingerprint: string; // Hash of data-val* attributes for change detection during re-scan listenerController: AbortController; currentError?: string; hasBeenInvalid: boolean; // Enables eager recovery (input-level validation after first error) @@ -29,17 +28,17 @@ export interface FormState { hasBeenSubmitted: boolean; // Enables input-level validation after first submit attempt } -/** CSS selector for elements that opt into client-side validation via data-val="true". */ -export const validatableElementSelector = 'input[data-val="true"], select[data-val="true"], textarea[data-val="true"]'; - /** * Central validation coordinator. Manages per-form and per-element state, runs validator * functions against field values, and delegates UI updates to ErrorDisplay. * * Invariants: * - Each validatable element is tracked in exactly one form's trackedElements set. - * - An element's rules are immutable after registration; attribute changes trigger - * unregister + re-register via DomScanner's fingerprint comparison. + * - An element's rules are immutable after registration. Re-registration is driven by the + * browser's custom-element lifecycle: when the owning element + * is removed and re-added during a DOM swap (enhanced navigation / streaming), its + * disconnectedCallback unregisters the elements and the new element's connectedCallback + * registers them again. * - setCustomValidity() is called on every validated element to drive the browser's * Constraint Validation API (:valid/:invalid pseudo-classes). */ diff --git a/src/Components/Web.JS/src/Validation/ValidationService.ts b/src/Components/Web.JS/src/Validation/ValidationService.ts deleted file mode 100644 index 69071054783b..000000000000 --- a/src/Components/Web.JS/src/Validation/ValidationService.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -import { registerCoreValidators } from './CoreValidators'; -import { DomScanner } from './DomScanner'; -import { ErrorDisplay } from './ErrorDisplay'; -import { EventManager } from './EventManager'; -import { ValidationEngine } from './ValidationEngine'; -import { ValidationOptions, ValidatableElement, ValidationService, Validator, ValidatorRegistry } from './ValidationTypes'; - -/** - * Creates and initializes a client-side form validation service. Registers built-in - * validators, scans the DOM for validatable elements, and attaches form event interceptors. - * - * @param options - Optional configuration (e.g., custom CSS class names). - * @returns A ValidationService instance for programmatic access. - */ -export function createValidationService(options?: ValidationOptions): ValidationService { - const registry = new ValidatorRegistry(); - registerCoreValidators(registry); - - const errorDisplay = new ErrorDisplay(options?.cssClasses); - const engine = new ValidationEngine(registry, errorDisplay); - const eventManager = new EventManager(engine); - const scanner = new DomScanner(engine, eventManager); - - eventManager.attachFormInterceptors(); - scanner.scan(document); - - return { - addValidator: (name: string, validator: Validator) => registry.set(name, validator), - scanRules: (elementOrSelector?: ParentNode | string) => scanner.scan(elementOrSelector), - validateField: (element: ValidatableElement) => engine.validateElement(element), - validateForm: (form: HTMLFormElement) => engine.validateForm(form).size === 0, - }; -} diff --git a/src/Components/Web.JS/src/Validation/ValidationTypes.ts b/src/Components/Web.JS/src/Validation/ValidationTypes.ts index 7b20c66ff8fc..a08811a349c1 100644 --- a/src/Components/Web.JS/src/Validation/ValidationTypes.ts +++ b/src/Components/Web.JS/src/Validation/ValidationTypes.ts @@ -15,8 +15,7 @@ export type ValidationContext = { * The result of a validator function. * - `success: true` means the field is valid. * - `success: false` means invalid; if `message` is provided it overrides the rule's - * default error message, otherwise the rule's own message (from `data-val-{rule}=...`) - * is used. + * default error message, otherwise the rule's own message (from the ValidationRule definition) is used. */ export type ValidationResult = { success: boolean; message?: string }; @@ -40,13 +39,11 @@ export interface ValidationOptions { /** * Public API for client-side form validation. Exposed as `Blazor.formValidation` - * (when embedded in blazor.web.js) or `window.__aspnetValidation` (standalone bundle). + * when embedded in blazor.web.js. */ export interface ValidationService { /** Registers a custom validator function for a given rule name (e.g., 'zipcode'). */ addValidator(name: string, validator: Validator): void; - /** Scans the DOM (or a subtree) for validatable elements and registers them. */ - scanRules(elementOrSelector?: ParentNode | string): void; /** Validates a single field element and updates its error display. */ validateField(element: ValidatableElement): boolean; /** Validates all tracked fields in the form. Returns true if all fields are valid. */ diff --git a/src/Components/Web.JS/src/Validation/Validators/CreditCard.ts b/src/Components/Web.JS/src/Validation/Validators/CreditCard.ts index 4d7edbac6fbf..7b48f89e169f 100644 --- a/src/Components/Web.JS/src/Validation/Validators/CreditCard.ts +++ b/src/Components/Web.JS/src/Validation/Validators/CreditCard.ts @@ -3,33 +3,31 @@ import { ValidationContext, ValidationResult, Validator, pass, fail } from '../ValidationTypes'; -// Validates credit card numbers using the Luhn algorithm (same as .NET CreditCardAttribute). -// Strips dashes and spaces before validation. Requires 13-19 digits. +// Validates credit card numbers using the Luhn algorithm, matching .NET's CreditCardAttribute +// exactly: iterate the value right-to-left, skip '-' and ' ' (ASCII space only), fail on any +// other non-ASCII-digit character, and accept when the Luhn checksum is a multiple of 10. export const creditCardValidator: Validator = (context: ValidationContext): ValidationResult => { const { value } = context; if (!value) { return pass(); } - // Strip dashes and spaces - const stripped = value.replace(/[\s-]/g, ''); + let checksum = 0; + let evenDigit = false; - // Only digits allowed after stripping - if (!/^\d+$/.test(stripped)) { - return fail(); - } + for (let i = value.length - 1; i >= 0; i--) { + const char = value[i]; - // Valid card numbers are 13-19 digits - if (stripped.length < 13 || stripped.length > 19) { - return fail(); - } + if (char < '0' || char > '9') { + if (char === '-' || char === ' ') { + continue; + } + return fail(); + } + + let digitValue = (char.charCodeAt(0) - 48) * (evenDigit ? 2 : 1); + evenDigit = !evenDigit; - // Luhn algorithm - let checksum = 0; - let doubleDigit = false; - for (let i = stripped.length - 1; i >= 0; i--) { - let digitValue = (stripped.charCodeAt(i) - 48) * (doubleDigit ? 2 : 1); - doubleDigit = !doubleDigit; while (digitValue > 0) { checksum += digitValue % 10; digitValue = Math.floor(digitValue / 10); diff --git a/src/Components/Web.JS/src/Validation/Validators/Phone.ts b/src/Components/Web.JS/src/Validation/Validators/Phone.ts index 31a4e0a5f868..e27b93f6ae1a 100644 --- a/src/Components/Web.JS/src/Validation/Validators/Phone.ts +++ b/src/Components/Web.JS/src/Validation/Validators/Phone.ts @@ -3,26 +3,31 @@ import { ValidationContext, ValidationResult, Validator, pass, fail } from '../ValidationTypes'; -// Validates phone numbers matching .NET's PhoneAttribute logic. -// Strips leading '+' and trailing extensions (ext./ext/x + digits). -// Requires at least one digit. Only allows digits, whitespace, and: - . ( ) +// Validates phone numbers with the same semantics as .NET's PhoneAttribute: +// removes every '+', trims trailing whitespace, strips a trailing extension +// ("ext."/"ext"/"x" followed by digits), requires at least one digit, and then allows +// only digits, whitespace, and the characters '-', '.', '(', ')'. +const phoneTrailingWhitespace = /\p{White_Space}+$/u; +const phoneExtension = /\p{White_Space}*(?:ext\.?|x)\p{White_Space}*\p{Nd}+$/iu; +const phoneHasDigit = /\p{Nd}/u; +const phoneAllowedChars = /^[\p{Nd}\p{White_Space}().-]+$/u; + export const phoneValidator: Validator = (context: ValidationContext): ValidationResult => { const { value } = context; if (!value) { return pass(); } - // Strip leading '+' (international prefix) - let phone = value.startsWith('+') ? value.substring(1) : value; - - // Strip trailing extension: "ext." / "ext" / "x" followed by digits - phone = phone.replace(/\s*(ext\.?|x)\s*\d+$/i, '').trimEnd(); + // Remove every '+', trim trailing whitespace, then strip a trailing extension. + const phone = value + .replace(/\+/gu, '') + .replace(phoneTrailingWhitespace, '') + .replace(phoneExtension, ''); - // Must contain at least one digit - if (!/\d/.test(phone)) { + // Must contain at least one digit. + if (!phoneHasDigit.test(phone)) { return fail(); } - // Only allow digits, whitespace, and: - . ( ) - return /^[\d\s\-.()\u00a0]+$/.test(phone) ? pass() : fail(); + return phoneAllowedChars.test(phone) ? pass() : fail(); }; diff --git a/src/Components/Web.JS/src/Validation/Validators/StringLength.ts b/src/Components/Web.JS/src/Validation/Validators/StringLength.ts index 5ac3411a84cc..4c068e695e12 100644 --- a/src/Components/Web.JS/src/Validation/Validators/StringLength.ts +++ b/src/Components/Web.JS/src/Validation/Validators/StringLength.ts @@ -8,7 +8,7 @@ import { ValidationContext, ValidationResult, Validator, pass, fail } from '../V // Throws if neither bound is defined (rule has no effect). export const stringLengthValidator: Validator = (context: ValidationContext): ValidationResult => { const { value, params } = context; - if (!params.min && !params.max) { + if (params.min === undefined && params.max === undefined) { throw new Error('length/minlength/maxlength validator requires at least one of "min" or "max" parameters.'); } @@ -16,15 +16,15 @@ export const stringLengthValidator: Validator = (context: ValidationContext): Va return pass(); } - if (params.min) { - const min = parseInt(params['min'], 10); + if (params.min !== undefined) { + const min = parseInt(params.min, 10); if (value.length < min) { return fail(); } } - if (params.max) { - const max = parseInt(params['max'], 10); + if (params.max !== undefined) { + const max = parseInt(params.max, 10); if (value.length > max) { return fail(); } diff --git a/src/Components/Web.JS/src/Validation/index.ts b/src/Components/Web.JS/src/Validation/index.ts index 7079da2ef49d..bfd40f44280b 100644 --- a/src/Components/Web.JS/src/Validation/index.ts +++ b/src/Components/Web.JS/src/Validation/index.ts @@ -3,9 +3,8 @@ // ES module facade for the client-side validation library. Importing this module // has no side effects - the consumer is responsible for calling -// `createValidationService()` to instantiate the service. +// `createBlazorValidation()` to instantiate the service using the Blazor rules adapter. -export { createValidationService } from './ValidationService'; export type { ValidationService, ValidationOptions, @@ -14,3 +13,5 @@ export type { Validator, ValidatableElement, } from './ValidationTypes'; + +export { createBlazorValidation, ensureNovalidateOnForms } from './Adapters/BlazorAdapter'; diff --git a/src/Components/Web.JS/test/Validation/Adapters/BlazorAdapter.test.ts b/src/Components/Web.JS/test/Validation/Adapters/BlazorAdapter.test.ts new file mode 100644 index 000000000000..c58095e9550b --- /dev/null +++ b/src/Components/Web.JS/test/Validation/Adapters/BlazorAdapter.test.ts @@ -0,0 +1,134 @@ +import { expect, test, describe, beforeAll, afterEach } from '@jest/globals'; +import { createBlazorValidation } from '../../../src/Validation/Adapters/BlazorAdapter'; +import { ValidationService } from '../../../src/Validation/ValidationTypes'; + +// The wire element name. Kept in sync with the .NET renderer and the (private) adapter constant. +const ClientValidationElementName = 'blazor-client-validation-data'; + +// A single service is used for all the tests in this file: customElements.define is global and irreversible, +// so the carrier element is defined once (by the first createBlazorValidation) and every test runs against +// the resulting service. +let service: ValidationService; + +beforeAll(() => { + // jsdom does not provide CSS.escape - the adapter uses it to look up inputs by name. + if (typeof globalThis.CSS === 'undefined') { + (globalThis as any).CSS = { escape: (v: string) => v.replace(/([^\w-])/g, '\\$1') }; + } + // jsdom does not implement attachInternals/ElementInternals. Provide a minimal getter-based + // polyfill mirroring the only behavior the adapter relies on: live ancestry-based form association. + if (typeof (HTMLElement.prototype as any).attachInternals !== 'function') { + (HTMLElement.prototype as any).attachInternals = function () { + const el = this as HTMLElement; + return { get form() { return el.closest('form'); } }; + }; + } + + // createBlazorValidation needs a carrier in the DOM to proceed; seed one, then clear. + document.body.appendChild(document.createElement(ClientValidationElementName)); + service = createBlazorValidation()!; + document.body.innerHTML = ''; +}); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +function fieldPayload(name: string, rules: unknown[]): string { + return JSON.stringify({ fields: [{ name, rules }] }); +} + +function makeTestForm(inputName: string, payload: string): { form: HTMLFormElement; input: HTMLInputElement; carrier: HTMLElement } { + const form = document.createElement('form'); + const input = document.createElement('input'); + input.name = inputName; + form.appendChild(input); + + const carrier = document.createElement(ClientValidationElementName); + carrier.setAttribute('data-rules', payload); + form.appendChild(carrier); + + document.body.appendChild(form); + return { form, input, carrier }; +} + +describe('carrier payload ingestion', () => { + // The happy path: a rendered carrier contains the validation rules and params, + // sets novalidate so the browser's native validation does not pre-empt the engine, and + // surfaces the rule's message. A length rule exercises the param round-trip in one pass. + test('registers fields with rule params and sets novalidate on connect', () => { + const { form, input } = makeTestForm('Name', fieldPayload('Name', [{ name: 'length', message: 'Too long.', params: { max: '3' } }])); + + expect(form.hasAttribute('novalidate')).toBe(true); + + input.value = 'abcd'; + expect(service.validateField(input)).toBe(false); + expect(input.validationMessage).toBe('Too long.'); + + input.value = 'ab'; + expect(service.validateField(input)).toBe(true); + }); + + // Registration is scoped to the carrier's own form: an identically named input in another form + // (with no carrier) is not registered. + test('registers only the carrier form\'s inputs', () => { + const { input: input1 } = makeTestForm('Email', fieldPayload('Email', [{ name: 'required', message: 'x' }])); + + const form2 = document.createElement('form'); + const input2 = document.createElement('input'); + input2.name = 'Email'; + form2.appendChild(input2); + document.body.appendChild(form2); + + expect(service.validateField(input1)).toBe(false); + expect(service.validateField(input2)).toBe(true); + }); + + // A field whose input is not present is skipped; the remaining fields still register. + test('skips payload fields with no matching input and registers the rest', () => { + const payload = JSON.stringify({ + fields: [ + { name: 'Ghost', rules: [{ name: 'required', message: 'x' }] }, + { name: 'Name', rules: [{ name: 'required', message: 'Required.' }] }, + ], + }); + const { input } = makeTestForm('Name', payload); + + expect(service.validateField(input)).toBe(false); + }); +}); + +describe('enhanced-navigation reconciliation', () => { + // Removing the carrier (as a morph does when navigating to a form-less page) unregisters its + // inputs, so the field validates as having no rules. + test('unregisters inputs when the carrier is removed', () => { + const { input, carrier } = makeTestForm('Name', fieldPayload('Name', [{ name: 'required', message: 'Required.' }])); + + expect(service.validateField(input)).toBe(false); + + carrier.remove(); + + expect(service.validateField(input)).toBe(true); + }); + + // An enhanced navigation morph that reuses the carrier and changes data-rules in place drives a rebuild via + // attributeChangedCallback: the old rules are dropped, the new ones registered, novalidate restored. + test('rebuilds rules and re-asserts novalidate when the carrier payload changes', () => { + const { form, input, carrier } = makeTestForm('Alpha', fieldPayload('Alpha', [{ name: 'required', message: 'Alpha required.' }])); + + expect(service.validateField(input)).toBe(false); + expect(input.validationMessage).toBe('Alpha required.'); + + // Simulate the morph: same input reused but renamed, novalidate stripped, payload changed. + input.name = 'Beta'; + form.removeAttribute('novalidate'); + carrier.setAttribute('data-rules', fieldPayload('Beta', [{ name: 'required', message: 'Beta required.' }])); + + // The rebuild restored novalidate and cleared the stale Alpha registration. + expect(form.hasAttribute('novalidate')).toBe(true); + expect(input.validationMessage).toBe(''); + + expect(service.validateField(input)).toBe(false); + expect(input.validationMessage).toBe('Beta required.'); + }); +}); diff --git a/src/Components/Web.JS/test/Validation/CoreValidators.test.ts b/src/Components/Web.JS/test/Validation/CoreValidators.test.ts index d2281ba1e527..9080c0a1d650 100644 --- a/src/Components/Web.JS/test/Validation/CoreValidators.test.ts +++ b/src/Components/Web.JS/test/Validation/CoreValidators.test.ts @@ -684,9 +684,9 @@ describe('urlValidator', () => { // Matches .NET PhoneAttribute behavior: // - Null/empty passes -// - Strips leading '+' and trailing extensions (ext./ext/x + digits) +// - Removes every '+' and trailing whitespace, strips a trailing extension (ext./ext/x + digits) // - Must contain at least one digit -// - Only digits, whitespace, and -.() allowed +// - Only digits, whitespace, and -.() allowed (digits/whitespace per Unicode, like .NET) describe('phoneValidator', () => { const phone = getValidator('phone'); @@ -733,6 +733,15 @@ describe('phoneValidator', () => { expect(phone(makeContext({ value: '+44 20 7946 0958' }))).toBe(true); }); + test('removes every plus sign, not only a leading one (matching .NET)', () => { + expect(phone(makeContext({ value: '+1 +555 1234' }))).toBe(true); + }); + + test('accepts Unicode decimal digits (matching .NET char.IsDigit)', () => { + // Arabic-Indic digits for "1234"; .NET's PhoneAttribute accepts these. + expect(phone(makeContext({ value: '\u0661\u0662\u0663\u0664' }))).toBe(true); + }); + test('accepts with ext. extension', () => { expect(phone(makeContext({ value: '555-1234 ext. 5678' }))).toBe(true); }); @@ -766,8 +775,7 @@ describe('phoneValidator', () => { }); // Matches .NET CreditCardAttribute behavior (Luhn algorithm). -// Empty values pass. Strips dashes and spaces before validation. -// Length check: 13-19 digits. +// Empty values pass. '-' and ' ' are skipped; any other non-digit fails. describe('creditcardValidator', () => { const creditcard = getValidator('creditcard'); @@ -808,14 +816,6 @@ describe('creditcardValidator', () => { expect(creditcard(makeContext({ value: '1234567890123456' }))).toBe(false); }); - test('rejects too short (12 digits)', () => { - expect(creditcard(makeContext({ value: '411111111111' }))).toBe(false); - }); - - test('rejects too long (20 digits)', () => { - expect(creditcard(makeContext({ value: '41111111111111111111' }))).toBe(false); - }); - test('rejects letters', () => { expect(creditcard(makeContext({ value: 'abcd1234abcd1234' }))).toBe(false); }); diff --git a/src/Components/Web.JS/test/Validation/DomScanner.test.ts b/src/Components/Web.JS/test/Validation/DomScanner.test.ts deleted file mode 100644 index 5f54b7a129b4..000000000000 --- a/src/Components/Web.JS/test/Validation/DomScanner.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { expect, test, describe } from '@jest/globals'; - -import { parseRules } from '../../src/Validation/DomScanner'; -import { ValidationRule } from '../../src/Validation/ValidationEngine'; -import { ValidatableElement } from '../../src/Validation/ValidationTypes'; - -function createElement(tag: string, attributes: Record): ValidatableElement { - const el = document.createElement(tag) as ValidatableElement; - for (const [name, value] of Object.entries(attributes)) { - el.setAttribute(name, value); - } - return el; -} - -function input(attributes: Record): ValidatableElement { - return createElement('input', attributes); -} - -describe('parseRules', () => { - test('returns empty array when element has no attributes', () => { - const el = input({}); - expect(parseRules(el)).toEqual([]); - }); - - test('returns empty array when element has no data-val-* attributes', () => { - const el = input({ type: 'text', name: 'age', class: 'form-control' }); - expect(parseRules(el)).toEqual([]); - }); - - test('parses a single rule with message only', () => { - const el = input({ 'data-val-required': 'This field is required.' }); - const rules = parseRules(el); - expect(rules).toEqual([ - { ruleName: 'required', errorMessage: 'This field is required.', params: {} }, - ]); - }); - - test('parses a single rule with message and one param', () => { - const el = input({ - 'data-val-maxlength': 'Too long.', - 'data-val-maxlength-max': '100', - }); - const rules = parseRules(el); - expect(rules).toEqual([ - { ruleName: 'maxlength', errorMessage: 'Too long.', params: { max: '100' } }, - ]); - }); - - test('parses a single rule with message and multiple params', () => { - const el = input({ - 'data-val-range': 'Value must be between 10 and 50.', - 'data-val-range-min': '10', - 'data-val-range-max': '50', - }); - const rules = parseRules(el); - expect(rules).toEqual([ - { - ruleName: 'range', - errorMessage: 'Value must be between 10 and 50.', - params: { min: '10', max: '50' }, - }, - ]); - }); - - test('parses multiple distinct rules on the same element', () => { - const el = input({ - 'data-val-required': 'This field is required.', - 'data-val-range': 'Must be between 1 and 100.', - 'data-val-range-min': '1', - 'data-val-range-max': '100', - }); - const rules = parseRules(el); - expect(rules).toHaveLength(2); - - const required = rules.find(r => r.ruleName === 'required'); - const range = rules.find(r => r.ruleName === 'range'); - - expect(required).toEqual({ ruleName: 'required', errorMessage: 'This field is required.', params: {} }); - expect(range).toEqual({ - ruleName: 'range', - errorMessage: 'Must be between 1 and 100.', - params: { min: '1', max: '100' }, - }); - }); - - test('result does not depend on attribute order (params before message)', () => { - const paramsFirst = input({ - 'data-val-range-min': '10', - 'data-val-range-max': '50', - 'data-val-range': 'Out of range.', - }); - const messageFirst = input({ - 'data-val-range': 'Out of range.', - 'data-val-range-min': '10', - 'data-val-range-max': '50', - }); - - const rulesA = parseRules(paramsFirst); - const rulesB = parseRules(messageFirst); - - expect(rulesA).toEqual(rulesB); - expect(rulesA).toEqual([ - { ruleName: 'range', errorMessage: 'Out of range.', params: { min: '10', max: '50' } }, - ]); - }); - - test('result does not depend on attribute order with multiple rules', () => { - const el1 = input({ - 'data-val-required': 'Required.', - 'data-val-range': 'Out of range.', - 'data-val-range-min': '1', - }); - const el2 = input({ - 'data-val-range-min': '1', - 'data-val-required': 'Required.', - 'data-val-range': 'Out of range.', - }); - - const rules1 = parseRules(el1); - const rules2 = parseRules(el2); - - // Both should have the same rules (order of rules in array may differ) - expect(rules1).toHaveLength(2); - expect(rules2).toHaveLength(2); - - for (const ruleName of ['required', 'range']) { - expect(rules1.find(r => r.ruleName === ruleName)).toEqual( - rules2.find(r => r.ruleName === ruleName) - ); - } - }); - - test('rule with params but no message gets empty errorMessage', () => { - const el = input({ - 'data-val-range-min': '10', - 'data-val-range-max': '50', - }); - const rules = parseRules(el); - expect(rules).toEqual([ - { ruleName: 'range', errorMessage: '', params: { min: '10', max: '50' } }, - ]); - }); - - test('rule with empty error message string', () => { - const el = input({ 'data-val-required': '' }); - const rules = parseRules(el); - expect(rules).toEqual([ - { ruleName: 'required', errorMessage: '', params: {} }, - ]); - }); - - test('param with empty value', () => { - const el = input({ - 'data-val-regex': 'Invalid format.', - 'data-val-regex-pattern': '', - }); - const rules = parseRules(el); - expect(rules).toEqual([ - { ruleName: 'regex', errorMessage: 'Invalid format.', params: { pattern: '' } }, - ]); - }); - - test('param name containing dashes is preserved in full', () => { - const el = input({ - 'data-val-myrule': 'Error.', - 'data-val-myrule-some-compound': 'value', - }); - const rules = parseRules(el); - expect(rules).toEqual([ - { ruleName: 'myrule', errorMessage: 'Error.', params: { 'some-compound': 'value' } }, - ]); - }); - - test('ignores data-val attribute (no trailing dash)', () => { - const el = input({ 'data-val': 'true' }); - expect(parseRules(el)).toEqual([]); - }); - - test('ignores attribute that is exactly "data-val-" (empty rule name)', () => { - const el = input({ 'data-val-': 'some value' }); - const rules = parseRules(el); - expect(rules).toEqual([]); - }); - - test('ignores non-data-val attributes alongside data-val-* attributes', () => { - const el = input({ - type: 'text', - name: 'email', - 'data-val-required': 'Email is required.', - 'aria-label': 'Email', - class: 'form-input', - }); - const rules = parseRules(el); - expect(rules).toEqual([ - { ruleName: 'required', errorMessage: 'Email is required.', params: {} }, - ]); - }); - - test('ignores unrelated data-* attributes alongside data-val-* attributes', () => { - const el = input({ - 'data-bind': 'value: name', - 'data-tooltip': 'Enter name', - 'data-val-required': 'Name is required.', - }); - const rules = parseRules(el); - expect(rules).toHaveLength(1); - expect(rules[0].ruleName).toBe('required'); - }); -}); - diff --git a/src/Components/Web.JS/test/Validation/EventManager.test.ts b/src/Components/Web.JS/test/Validation/EventManager.test.ts new file mode 100644 index 000000000000..093e1f0fb05e --- /dev/null +++ b/src/Components/Web.JS/test/Validation/EventManager.test.ts @@ -0,0 +1,232 @@ +import { expect, test, describe, beforeAll, beforeEach, afterEach, jest } from '@jest/globals'; +import { registerCoreValidators } from '../../src/Validation/CoreValidators'; +import { ErrorDisplay } from '../../src/Validation/ErrorDisplay'; +import { EventManager } from '../../src/Validation/EventManager'; +import { ElementState, ValidationEngine } from '../../src/Validation/ValidationEngine'; +import { ValidatorRegistry } from '../../src/Validation/ValidationTypes'; + +beforeAll(() => { + // jsdom does not provide CSS.escape; the radio fan-out and DOM helpers use it. + if (typeof globalThis.CSS === 'undefined') { + (globalThis as any).CSS = { escape: (v: string) => v.replace(/([^\w-])/g, '\\$1') }; + } + // jsdom performs no layout, so offsetParent is always null and shouldSkipElement (used by validateForm) + // would skip every field. + // Test mock: visible fields report a non-null offsetParent, fields marked hidden report null. + Object.defineProperty(HTMLElement.prototype, 'offsetParent', { + configurable: true, + get(): Element | null { + return (this as HTMLElement).hidden ? null : ((this as HTMLElement).closest('form') ?? document.body); + }, + }); +}); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +function makeHarness() { + const registry = new ValidatorRegistry(); + registerCoreValidators(registry); + const errorDisplay = new ErrorDisplay(); + const engine = new ValidationEngine(registry, errorDisplay); + const eventManager = new EventManager(engine); + return { engine, eventManager }; +} + +// Adds a text input with a single 'required' rule and registers it with the engine. +function addRequiredField( + engine: ValidationEngine, + form: HTMLFormElement, + name: string, + value = '', + triggerEvents = 'default', +): HTMLInputElement { + const input = document.createElement('input'); + input.name = name; + input.value = value; + form.appendChild(input); + const state: ElementState = { + rules: [{ ruleName: 'required', errorMessage: `${name} is required.`, params: {} }], + form, + triggerEvents, + listenerController: new AbortController(), + hasBeenInvalid: false, + }; + engine.registerElement(input, form, state); + return input; +} + +describe('EventManager radio fan-out (eager recovery)', () => { + // Only the first radio of a group is registered with the engine, but listeners must be + // attached to every radio in the group. Selecting a non-first radio must therefore clear + // the error without a submit. Without the fan-out the non-first radio has no listener and + // the error would persist. + test('selecting a non-first radio in the group clears the error', () => { + const { engine, eventManager } = makeHarness(); + + const form = document.createElement('form'); + const radios = ['Red', 'Green', 'Blue'].map(value => { + const radio = document.createElement('input'); + radio.type = 'radio'; + radio.name = 'Color'; + radio.value = value; + form.appendChild(radio); + return radio; + }); + document.body.appendChild(form); + + const [first, , third] = radios; + + const state: ElementState = { + rules: [{ ruleName: 'required', errorMessage: 'Pick a color.', params: {} }], + form, + triggerEvents: 'default', + listenerController: new AbortController(), + hasBeenInvalid: false, + }; + engine.registerElement(first, form, state); + eventManager.attachInputListeners(first); + + // Force an initial failure: no radio in the group is selected. + expect(engine.validateElement(first)).toBe(false); + expect(engine.getElementState(first)?.currentError).toBe('Pick a color.'); + + // Select a non-first radio and dispatch 'change' on it. + third.checked = true; + third.dispatchEvent(new Event('change', { bubbles: true })); + + expect(engine.getElementState(first)?.currentError).toBeUndefined(); + }); +}); + +describe('EventManager form submission interception', () => { + let engine: ValidationEngine; + let eventManager: EventManager; + + beforeEach(() => { + ({ engine, eventManager } = makeHarness()); + eventManager.attachFormInterceptors(); + }); + + afterEach(() => { + eventManager.detachFormInterceptors(); + }); + + // Dispatches a cancelable submit on the form (optionally from a submitter) and captures whether + // the interceptor blocked it and what 'validationcomplete' reported. + function dispatchSubmit(form: HTMLFormElement, submitter?: HTMLElement) { + const event = new Event('submit', { bubbles: true, cancelable: true }); + if (submitter) { + Object.defineProperty(event, 'submitter', { value: submitter }); + } + const preventDefault = jest.spyOn(event, 'preventDefault'); + const stopPropagation = jest.spyOn(event, 'stopPropagation'); + const completions: Array<{ valid: boolean; errors: Map }> = []; + form.addEventListener('validationcomplete', e => completions.push((e as CustomEvent).detail)); + form.dispatchEvent(event); + return { preventDefault, stopPropagation, completions }; + } + + test('invalid tracked form: blocks the submit and reports validationcomplete with the errors', () => { + const form = document.createElement('form'); + document.body.appendChild(form); + addRequiredField(engine, form, 'Name'); // empty -> invalid + + const { preventDefault, stopPropagation, completions } = dispatchSubmit(form); + + expect(preventDefault).toHaveBeenCalled(); + expect(stopPropagation).toHaveBeenCalled(); + expect(completions).toHaveLength(1); + expect(completions[0].valid).toBe(false); + expect(completions[0].errors.get('Name')).toBe('Name is required.'); + }); + + test('valid tracked form: allows the submit and reports validationcomplete as valid', () => { + const form = document.createElement('form'); + document.body.appendChild(form); + addRequiredField(engine, form, 'Name', 'Ada'); // non-empty -> valid + + const { preventDefault, completions } = dispatchSubmit(form); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(completions).toHaveLength(1); + expect(completions[0].valid).toBe(true); + expect(completions[0].errors.size).toBe(0); + }); + + test('untracked form: not intercepted', () => { + const form = document.createElement('form'); + document.body.appendChild(form); // no fields registered -> no form state + + const { preventDefault, completions } = dispatchSubmit(form); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(completions).toHaveLength(0); + }); + + test('formnovalidate submitter: not intercepted even when the form is invalid', () => { + const form = document.createElement('form'); + document.body.appendChild(form); + addRequiredField(engine, form, 'Name'); // empty -> would be invalid + + const button = document.createElement('button'); + button.setAttribute('formnovalidate', ''); + form.appendChild(button); + + const { preventDefault, completions } = dispatchSubmit(form, button); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(completions).toHaveLength(0); + }); +}); + +describe('EventManager validation triggers (lazy validation, eager recovery)', () => { + // Default gating: 'change' always validates, but 'input' only validates once the field has been + // invalid or the form has been submitted. Validity is observed via validationMessage, which the + // engine sets through setCustomValidity. + test('before any error or submit: input does not validate, change does', () => { + const { engine, eventManager } = makeHarness(); + const form = document.createElement('form'); + document.body.appendChild(form); + const input = addRequiredField(engine, form, 'Name'); // empty -> would be invalid + eventManager.attachInputListeners(input); + + input.dispatchEvent(new Event('input')); + expect(input.validationMessage).toBe(''); // gated out, not validated + + input.dispatchEvent(new Event('change')); + expect(input.validationMessage).toBe('Name is required.'); + }); + + test('after the field has been invalid: input validates (eager recovery)', () => { + const { engine, eventManager } = makeHarness(); + const form = document.createElement('form'); + document.body.appendChild(form); + const input = addRequiredField(engine, form, 'Name'); + eventManager.attachInputListeners(input); + + // First error via change flips hasBeenInvalid. + input.dispatchEvent(new Event('change')); + expect(input.validationMessage).toBe('Name is required.'); + + // Now input validates: fixing the value and firing input clears the error. + input.value = 'Ada'; + input.dispatchEvent(new Event('input')); + expect(input.validationMessage).toBe(''); + }); + + test('after the form has been submitted: input validates', () => { + const { engine, eventManager } = makeHarness(); + const form = document.createElement('form'); + document.body.appendChild(form); + const input = addRequiredField(engine, form, 'Name'); + eventManager.attachInputListeners(input); + + // Simulate a prior submit attempt without routing through validateForm. + engine.getFormState(form)!.hasBeenSubmitted = true; + + input.dispatchEvent(new Event('input')); + expect(input.validationMessage).toBe('Name is required.'); + }); +}); diff --git a/src/Components/Web.JS/test/Validation/ValidationEngine.test.ts b/src/Components/Web.JS/test/Validation/ValidationEngine.test.ts new file mode 100644 index 000000000000..5257dea7d883 --- /dev/null +++ b/src/Components/Web.JS/test/Validation/ValidationEngine.test.ts @@ -0,0 +1,78 @@ +import { expect, test, describe, beforeAll, afterEach } from '@jest/globals'; +import { registerCoreValidators } from '../../src/Validation/CoreValidators'; +import { ErrorDisplay } from '../../src/Validation/ErrorDisplay'; +import { ElementState, ValidationEngine } from '../../src/Validation/ValidationEngine'; +import { ValidatorRegistry } from '../../src/Validation/ValidationTypes'; + +beforeAll(() => { + // jsdom does not provide CSS.escape; the engine's DOM helpers use it. + if (typeof globalThis.CSS === 'undefined') { + (globalThis as any).CSS = { escape: (v: string) => v.replace(/([^\w-])/g, '\\$1') }; + } + // jsdom performs no layout, so offsetParent is always null and shouldSkipElement (used by validateForm) + // would skip every field. + // Test mock: visible fields report a non-null offsetParent, fields marked hidden report null. + Object.defineProperty(HTMLElement.prototype, 'offsetParent', { + configurable: true, + get(): Element | null { + return (this as HTMLElement).hidden ? null : ((this as HTMLElement).closest('form') ?? document.body); + }, + }); +}); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +function makeEngine(): ValidationEngine { + const registry = new ValidatorRegistry(); + registerCoreValidators(registry); + return new ValidationEngine(registry, new ErrorDisplay()); +} + +// Adds a text input with a single 'required' rule and registers it with the engine. +function addRequiredField(engine: ValidationEngine, form: HTMLFormElement, name: string): HTMLInputElement { + const input = document.createElement('input'); + input.name = name; + form.appendChild(input); + const state: ElementState = { + rules: [{ ruleName: 'required', errorMessage: `${name} is required.`, params: {} }], + form, + triggerEvents: 'default', + listenerController: new AbortController(), + hasBeenInvalid: false, + }; + engine.registerElement(input, form, state); + return input; +} + +describe('ValidationEngine.validateForm', () => { + test('validates fields, skips hidden fields, focuses the first invalid, and returns the error map', () => { + const engine = makeEngine(); + const form = document.createElement('form'); + document.body.appendChild(form); + + const first = addRequiredField(engine, form, 'First'); // visible, empty -> invalid + const hidden = addRequiredField(engine, form, 'Hidden'); // will be hidden -> skipped + addRequiredField(engine, form, 'Last'); // visible, empty -> invalid + + // Seed a stale error on the field before hiding it, so the skip path is proven to clear it. + engine.validateElement(hidden); + expect(hidden.validationMessage).toBe('Hidden is required.'); + hidden.hidden = true; + + const errors = engine.validateForm(form); + + // Only the two visible invalid fields are reported, keyed by field name. + expect([...errors.keys()]).toEqual(['First', 'Last']); + expect(errors.get('First')).toBe('First is required.'); + expect(errors.get('Last')).toBe('Last is required.'); + + // The skipped hidden field was marked valid: its prior error is cleared. + expect(hidden.validationMessage).toBe(''); + expect(engine.getElementState(hidden)?.currentError).toBeUndefined(); + + // The first invalid field receives focus. + expect(document.activeElement).toBe(first); + }); +}); diff --git a/src/Components/Web/src/Forms/ClientValidation/ClientValidationData.cs b/src/Components/Web/src/Forms/ClientValidation/ClientValidationData.cs new file mode 100644 index 000000000000..db322077c765 --- /dev/null +++ b/src/Components/Web/src/Forms/ClientValidation/ClientValidationData.cs @@ -0,0 +1,62 @@ +// 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.DependencyInjection; + +namespace Microsoft.AspNetCore.Components.Forms.ClientValidation; + +/// +/// Renders the per-form client-side validation carrier when client-side validation is activated +/// for the surrounding form and the registered produces some content. +/// +internal sealed class ClientValidationData : IComponent +{ + private RenderHandle _handle; + private bool _hasRendered; + + [Inject] private IServiceProvider Services { get; set; } = default!; + + [CascadingParameter] private EditContext? CurrentEditContext { get; set; } + + public void Attach(RenderHandle renderHandle) => _handle = renderHandle; + + public Task SetParametersAsync(ParameterView parameters) + { + parameters.SetParameterProperties(this); + + if (_hasRendered) + { + return Task.CompletedTask; + } + + _hasRendered = true; + + // No surrounding EditForm, or no validator activated client validation for this form. + if (CurrentEditContext is null + || !CurrentEditContext.Properties.TryGetValue(typeof(DataAnnotationsValidator), out _)) + { + return Task.CompletedTask; + } + + var provider = Services.GetService(); + if (provider is null) + { + return Task.CompletedTask; + } + + // Inputs that rendered under this EditContext registered their field + HTML name into the registry. + var registry = RenderedFieldRegistry.Get(CurrentEditContext); + if (registry is null || registry.Fields.Count == 0) + { + return Task.CompletedTask; + } + + var fragment = provider.RenderClientValidationRules(CurrentEditContext, registry.Fields); + if (fragment is not null) + { + _handle.Render(fragment); + } + + return Task.CompletedTask; + } +} diff --git a/src/Components/Web/src/Forms/ClientValidation/ClientValidationProvider.cs b/src/Components/Web/src/Forms/ClientValidation/ClientValidationProvider.cs new file mode 100644 index 000000000000..003b50fb7fef --- /dev/null +++ b/src/Components/Web/src/Forms/ClientValidation/ClientValidationProvider.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. + +namespace Microsoft.AspNetCore.Components.Forms.ClientValidation; + +/// +/// Provides client-side validation metadata for a form's model. +/// +public abstract class ClientValidationProvider +{ + /// + /// Returns a that renders the client-side validation rules for + /// the form's rendered fields, or when no client-side validation data + /// applies (for example when none of the rendered fields are validated on the server). + /// + /// The of the form being rendered. + /// + /// The fields an input was rendered for, keyed by with the + /// rendered HTML name as the value. Implementations build client-side rules only for + /// these fields. + /// + /// + /// A that emits the rules markup, or when + /// there is nothing to render. + /// + public abstract RenderFragment? RenderClientValidationRules( + EditContext editContext, + IReadOnlyDictionary renderedFields); +} diff --git a/src/Components/Web/src/Forms/ClientValidation/RenderedFieldRegistry.cs b/src/Components/Web/src/Forms/ClientValidation/RenderedFieldRegistry.cs new file mode 100644 index 000000000000..9e6c0fa6253c --- /dev/null +++ b/src/Components/Web/src/Forms/ClientValidation/RenderedFieldRegistry.cs @@ -0,0 +1,43 @@ +// 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.Forms.ClientValidation; + +/// +/// Per-form registry of the inputs that rendered under an in static +/// SSR, mapping each to the HTML name it rendered with. +/// Stored in keyed by typeof(RenderedFieldRegistry). +/// Written by InputBase as inputs initialize and read by +/// when it emits the client-validation payload. Lives entirely within this assembly; the SSR +/// provider receives the field map as a plain dictionary, never this type. +/// +internal sealed class RenderedFieldRegistry +{ + private readonly Dictionary _fields = []; + + /// Gets the registered fields keyed by identifier, valued by rendered HTML name. + public IReadOnlyDictionary Fields => _fields; + + /// Records that an input rendered for the field, with the HTML name it posts under. + public void Register(in FieldIdentifier fieldIdentifier, string renderedName) + => _fields[fieldIdentifier] = renderedName; + + /// Gets the registry for the context, creating and storing one if absent. + public static RenderedFieldRegistry GetOrCreate(EditContext editContext) + { + if (editContext.Properties.TryGetValue(typeof(RenderedFieldRegistry), out var existing)) + { + return (RenderedFieldRegistry)existing; + } + + var registry = new RenderedFieldRegistry(); + editContext.Properties[typeof(RenderedFieldRegistry)] = registry; + return registry; + } + + /// Gets the registry for the context, or if none was created. + public static RenderedFieldRegistry? Get(EditContext editContext) + => editContext.Properties.TryGetValue(typeof(RenderedFieldRegistry), out var existing) + ? (RenderedFieldRegistry)existing + : null; +} diff --git a/src/Components/Web/src/Forms/EditForm.cs b/src/Components/Web/src/Forms/EditForm.cs index 9a686632a7fa..87975ee917ed 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.Forms.ClientValidation; using Microsoft.AspNetCore.Components.Forms.Mapping; using Microsoft.AspNetCore.Components.Rendering; @@ -167,7 +168,15 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.OpenComponent>(7); builder.AddComponentParameter(7, "IsFixed", true); builder.AddComponentParameter(8, "Value", _editContext); - builder.AddComponentParameter(9, "ChildContent", ChildContent?.Invoke(_editContext)); + builder.AddComponentParameter(9, "ChildContent", (RenderFragment)(childBuilder => + { + if (ChildContent != null) + { + childBuilder.AddContent(0, ChildContent(_editContext)); + } + childBuilder.OpenComponent(1); + childBuilder.CloseComponent(); + })); builder.CloseComponent(); builder.CloseElement(); diff --git a/src/Components/Web/src/Forms/InputBase.cs b/src/Components/Web/src/Forms/InputBase.cs index f3a3fd81431a..81f1089a603c 100644 --- a/src/Components/Web/src/Forms/InputBase.cs +++ b/src/Components/Web/src/Forms/InputBase.cs @@ -268,6 +268,12 @@ public override Task SetParametersAsync(ParameterView parameters) EditContext = CascadedEditContext; EditContext.OnValidationStateChanged += _validationStateChangedHandler; _shouldGenerateFieldNames = EditContext.ShouldUseFieldIdentifiers; + + if (AssignedRenderMode is null) + { + // Register the input for client-side validation if rendered in static SSR mode. + RenderedFieldRegistry.GetOrCreate(EditContext).Register(FieldIdentifier, NameAttributeValue); + } } else { @@ -290,7 +296,6 @@ public override Task SetParametersAsync(ParameterView parameters) } UpdateAdditionalValidationAttributes(); - MergeClientValidationAttributes(); // For derived components, retain the usual lifecycle with OnInit/OnParametersSet/etc. return base.SetParametersAsync(ParameterView.Empty); @@ -387,37 +392,6 @@ private static bool ConvertToDictionary(IReadOnlyDictionary? sou return newDictionaryCreated; } - /// - /// Merges client-side validation attributes (data-val-*) into AdditionalAttributes when - /// an IClientValidationService is available in EditContext.Properties. This is set by - /// DataAnnotationsValidator in static SSR mode. Uses first-wins semantics so that - /// developer-specified attributes in AdditionalAttributes take precedence over generated ones. - /// - private void MergeClientValidationAttributes() - { - if (EditContext?.Properties.TryGetValue(typeof(IClientValidationService), out var serviceObj) != true - || serviceObj is not IClientValidationService service) - { - return; - } - - var htmlAttributes = service.GetClientValidationAttributes(FieldIdentifier); - if (htmlAttributes is null) - { - return; - } - - if (ConvertToDictionary(AdditionalAttributes, out var additionalAttributes)) - { - AdditionalAttributes = additionalAttributes; - } - - foreach (var (key, value) in htmlAttributes) - { - additionalAttributes.TryAdd(key, value); - } - } - /// protected virtual void Dispose(bool disposing) { diff --git a/src/Components/Web/src/Forms/InputRadio.cs b/src/Components/Web/src/Forms/InputRadio.cs index 8806b88200c9..6b7088d37a31 100644 --- a/src/Components/Web/src/Forms/InputRadio.cs +++ b/src/Components/Web/src/Forms/InputRadio.cs @@ -62,23 +62,16 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) { Debug.Assert(Context != null); - // Render client validation attributes (data-val-*) only on the first radio in the group. - // Subsequent radios get null because we clear the context's attributes after consuming them. - // The JS validation library only needs to discover the rules once per radio group. - var clientValidationAttributes = Context.ClientValidationAttributes; - Context.ClientValidationAttributes = null; - builder.OpenElement(0, "input"); - builder.AddMultipleAttributes(1, clientValidationAttributes); - builder.AddMultipleAttributes(2, AdditionalAttributes); - builder.AddAttributeIfNotNullOrEmpty(3, "class", AttributeUtilities.CombineClassNames(AdditionalAttributes, Context.FieldClass)); - builder.AddAttribute(4, "type", "radio"); - builder.AddAttribute(5, "name", Context.GroupName); - builder.AddAttribute(6, "value", BindConverter.FormatValue(Value?.ToString())); - builder.AddAttribute(7, "checked", Context.CurrentValue?.Equals(Value) == true ? GetToggledTrueValue() : null); - builder.AddAttribute(8, "onchange", Context.ChangeEventCallback); + builder.AddMultipleAttributes(1, AdditionalAttributes); + builder.AddAttributeIfNotNullOrEmpty(2, "class", AttributeUtilities.CombineClassNames(AdditionalAttributes, Context.FieldClass)); + builder.AddAttribute(3, "type", "radio"); + builder.AddAttribute(4, "name", Context.GroupName); + builder.AddAttribute(5, "value", BindConverter.FormatValue(Value?.ToString())); + builder.AddAttribute(6, "checked", Context.CurrentValue?.Equals(Value) == true ? GetToggledTrueValue() : null); + builder.AddAttribute(7, "onchange", Context.ChangeEventCallback); builder.SetUpdatesAttributeName("checked"); - builder.AddElementReferenceCapture(9, __inputReference => Element = __inputReference); + builder.AddElementReferenceCapture(8, __inputReference => Element = __inputReference); builder.CloseElement(); } diff --git a/src/Components/Web/src/Forms/InputRadioContext.cs b/src/Components/Web/src/Forms/InputRadioContext.cs index c319beb81ee2..8dc9c2f72b77 100644 --- a/src/Components/Web/src/Forms/InputRadioContext.cs +++ b/src/Components/Web/src/Forms/InputRadioContext.cs @@ -18,9 +18,6 @@ internal sealed class InputRadioContext public string? GroupName { get; set; } public string? FieldClass { get; set; } - // Client validation attributes (data-val-*) from the parent InputRadioGroup. - public IReadOnlyDictionary? ClientValidationAttributes { get; set; } - public InputRadioContext(IInputRadioValueProvider valueProvider, InputRadioContext? parentContext, EventCallback changeEventCallback) { _valueProvider = valueProvider; diff --git a/src/Components/Web/src/Forms/InputRadioGroup.cs b/src/Components/Web/src/Forms/InputRadioGroup.cs index 442fcd5a2a28..ba258e3052f0 100644 --- a/src/Components/Web/src/Forms/InputRadioGroup.cs +++ b/src/Components/Web/src/Forms/InputRadioGroup.cs @@ -63,13 +63,6 @@ protected override void OnParametersSet() } _context.FieldClass = EditContext?.FieldCssClass(FieldIdentifier); - - // Pass client validation attributes to child InputRadio components via shared context. - // Unlike MVC (which uses FormContext to emit data-val-* on only the first radio button), - // Blazor's component model renders children independently. We achieve the same first-only - // behavior by mutating the shared context: the first InputRadio reads the attributes, - // renders them, and clears the property so subsequent radios in the group get nothing. - _context.ClientValidationAttributes = ExtractClientValidationAttributes(); } /// @@ -88,31 +81,4 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) /// protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage) => this.TryParseSelectableValueFromString(value, out result, out validationErrorMessage); - - /// - /// Extracts data-val-* client validation attributes from AdditionalAttributes so they - /// can be passed to child InputRadio components via InputRadioContext. InputRadioGroup - /// itself doesn't render an HTML element, so it can't carry these attributes directly. - /// - private IReadOnlyDictionary? ExtractClientValidationAttributes() - { - if (AdditionalAttributes is null) - { - return null; - } - - Dictionary? result = null; - foreach (var (key, value) in AdditionalAttributes) - { - // Match "data-val" exactly and "data-val-*" (with dash), but not "data-value" or other unrelated attributes. - if (string.Equals(key, "data-val", StringComparison.OrdinalIgnoreCase) - || key.StartsWith("data-val-", StringComparison.OrdinalIgnoreCase)) - { - result ??= new(); - result[key] = value; - } - } - - return result; - } } diff --git a/src/Components/Web/src/Forms/ValidationMessage.cs b/src/Components/Web/src/Forms/ValidationMessage.cs index 15184854284d..8a9d1b383264 100644 --- a/src/Components/Web/src/Forms/ValidationMessage.cs +++ b/src/Components/Web/src/Forms/ValidationMessage.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Linq.Expressions; -using Microsoft.AspNetCore.Components.Forms.ClientValidation; using Microsoft.AspNetCore.Components.Rendering; namespace Microsoft.AspNetCore.Components.Forms; @@ -70,33 +69,6 @@ protected override void OnParametersSet() /// protected override void BuildRenderTree(RenderTreeBuilder builder) - { - if (CurrentEditContext.Properties.TryGetValue(typeof(IClientValidationService), out var serviceObj) - && serviceObj is IClientValidationService) - { - RenderForClientValidation(builder); - return; - } - - foreach (var message in CurrentEditContext.GetValidationMessages(_fieldIdentifier)) - { - builder.OpenElement(0, "div"); - builder.AddAttribute(1, "class", "validation-message"); - builder.AddMultipleAttributes(2, AdditionalAttributes); - builder.AddContent(3, message); - builder.CloseElement(); - } - } - - /// - /// Renders validation messages with data-valmsg-for and data-valmsg-replace attributes - /// for the JS validation library. The first message element carries the attributes so the - /// JS library can find it and replace its content. If no server-side messages exist yet, - /// an empty placeholder div is rendered for JS to populate when client validation runs. - /// Server-rendered sibling messages (without data-valmsg-for) are cleaned up by the JS library - /// when it inserts the client-side validation messages. - /// - private void RenderForClientValidation(RenderTreeBuilder builder) { // Use HtmlFieldPrefix to compute the field name consistently with InputBase. // In nested Editor scenarios, FieldPrefix adjusts the name to match the rendered input's name attribute. @@ -109,7 +81,8 @@ private void RenderForClientValidation(RenderTreeBuilder builder) builder.AddAttribute(1, "class", "validation-message"); if (first) { - // First message element will be used as container for client-side validation message + // First message element carries data-valmsg-for/replace so the JS client-validation + // engine can locate it and swap its content with client-side messages. builder.AddAttribute(2, "data-valmsg-for", fieldName); builder.AddAttribute(3, "data-valmsg-replace", "true"); first = false; @@ -121,7 +94,8 @@ private void RenderForClientValidation(RenderTreeBuilder builder) if (first) { - // No messages - render empty placeholder for JS to find + // No server-side messages - render an empty placeholder so the JS engine has an + // element to populate when client validation runs. Inert when no JS engine is present. builder.OpenElement(0, "div"); builder.AddAttribute(1, "class", "validation-message"); builder.AddAttribute(2, "data-valmsg-for", fieldName); diff --git a/src/Components/Web/src/Forms/ValidationSummary.cs b/src/Components/Web/src/Forms/ValidationSummary.cs index 6000bf0ff7eb..2b8a966d949b 100644 --- a/src/Components/Web/src/Forms/ValidationSummary.cs +++ b/src/Components/Web/src/Forms/ValidationSummary.cs @@ -1,7 +1,6 @@ // 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.ClientValidation; using Microsoft.AspNetCore.Components.Rendering; namespace Microsoft.AspNetCore.Components.Forms; @@ -60,55 +59,17 @@ protected override void OnParametersSet() /// protected override void BuildRenderTree(RenderTreeBuilder builder) { - // As an optimization, only evaluate the messages enumerable once, and - // only produce the enclosing
      if there's at least one message, - // or client-side validation is enabled. - var validationMessages = Model is null ? - CurrentEditContext.GetValidationMessages() : - CurrentEditContext.GetValidationMessages(new FieldIdentifier(Model, string.Empty)); - - if (CurrentEditContext.Properties.TryGetValue(typeof(IClientValidationService), out var serviceObj) - && serviceObj is IClientValidationService) - { - RenderForClientValidation(builder, validationMessages); - return; - } - - var first = true; - foreach (var error in validationMessages) - { - if (first) - { - first = false; - - builder.OpenElement(0, "ul"); - builder.AddAttribute(1, "class", "validation-errors"); - builder.AddMultipleAttributes(2, AdditionalAttributes); - } - - builder.OpenElement(3, "li"); - builder.AddAttribute(4, "class", "validation-message"); - builder.AddContent(5, error); - builder.CloseElement(); - } - - if (!first) - { - // We have at least one validation message. - builder.CloseElement(); - } - } + var validationMessages = Model is null + ? CurrentEditContext.GetValidationMessages() + : CurrentEditContext.GetValidationMessages(new FieldIdentifier(Model, string.Empty)); - /// - /// Renders a validation summary container with data-valmsg-summary="true" for the JS - /// validation library. Sets the initial CSS class based on whether server-rendered messages - /// exist: validation-summary-errors when non-empty (so CSS that hides validation-summary-valid - /// won't suppress initial server errors), validation-summary-valid when empty. - /// - private void RenderForClientValidation(RenderTreeBuilder builder, IEnumerable validationMessages) - { + // Materialize once so the count drives the initial CSS class while we still iterate + // the same set for rendering. var messages = new List(validationMessages); + // Wrapping div carries data-valmsg-summary="true" so the JS client-validation engine + // can locate the summary, plus a CSS class reflecting current state. Inert when no + // JS engine is present. builder.OpenElement(0, "div"); builder.AddAttribute(1, "data-valmsg-summary", "true"); builder.AddAttribute(2, "class", messages.Count > 0 ? "validation-summary-errors" : "validation-summary-valid"); diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index c80f663aa897..a80997f322da 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -7,6 +7,9 @@ Microsoft.AspNetCore.Components.Web.EnvironmentView.Exclude.get -> string? Microsoft.AspNetCore.Components.Web.EnvironmentView.Exclude.set -> void Microsoft.AspNetCore.Components.Web.EnvironmentView.Include.get -> string? Microsoft.AspNetCore.Components.Web.EnvironmentView.Include.set -> void +abstract Microsoft.AspNetCore.Components.Forms.ClientValidation.ClientValidationProvider.RenderClientValidationRules(Microsoft.AspNetCore.Components.Forms.EditContext! editContext, System.Collections.Generic.IReadOnlyDictionary! renderedFields) -> Microsoft.AspNetCore.Components.RenderFragment? +Microsoft.AspNetCore.Components.Forms.ClientValidation.ClientValidationProvider +Microsoft.AspNetCore.Components.Forms.ClientValidation.ClientValidationProvider.ClientValidationProvider() -> void Microsoft.AspNetCore.Components.Routing.NavLink.RelativeToCurrentUri.get -> bool Microsoft.AspNetCore.Components.Routing.NavLink.RelativeToCurrentUri.set -> void *REMOVED*Microsoft.AspNetCore.Components.Forms.RemoteBrowserFileStreamOptions diff --git a/src/Components/Web/test/Forms/ClientValidation/ClientValidationDataTest.cs b/src/Components/Web/test/Forms/ClientValidation/ClientValidationDataTest.cs new file mode 100644 index 000000000000..dbf18addb0be --- /dev/null +++ b/src/Components/Web/test/Forms/ClientValidation/ClientValidationDataTest.cs @@ -0,0 +1,253 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.ComponentModel.DataAnnotations; +using System.Linq.Expressions; +using Microsoft.AspNetCore.Components.Forms.Mapping; +using Microsoft.AspNetCore.Components.Infrastructure; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Test.Helpers; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Components.Forms.ClientValidation; + +public class ClientValidationDataTest +{ + private const string CarrierElementName = "blazor-client-validation-data"; + + [Fact] + public async Task Renders_ProviderFragment_WhenActivatedRegistryPopulatedAndProviderReturnsFragment() + { + var editContext = new EditContext(new object()); + Activate(editContext); + RegisterField(editContext, "F"); + + var renderer = CreateRenderer(provider: new FakeProvider(CarrierFragment())); + + var elementName = await RenderClientValidationDataAndGetCarrierElementName(renderer, editContext); + + Assert.Equal(CarrierElementName, elementName); + } + + [Fact] + public async Task NoOp_WhenNotActivated() + { + var editContext = new EditContext(new object()); + // Activation flag deliberately not written. + RegisterField(editContext, "F"); + + var renderer = CreateRenderer(provider: new FakeProvider(CarrierFragment())); + + var elementName = await RenderClientValidationDataAndGetCarrierElementName(renderer, editContext); + + Assert.Null(elementName); + } + + [Fact] + public async Task NoOp_WhenNoFieldsRegistered() + { + // Activated and the provider would return a fragment, but no input registered a field + // (e.g. interactive render modes, where InputBase does not register). The component + // short-circuits at the registry check before invoking the provider. + var editContext = new EditContext(new object()); + Activate(editContext); + + var renderer = CreateRenderer(provider: new FakeProvider(CarrierFragment())); + + var elementName = await RenderClientValidationDataAndGetCarrierElementName(renderer, editContext); + + Assert.Null(elementName); + } + + [Fact] + public async Task NoOp_WhenProviderNotRegistered() + { + // Server / WASM / interactive paths: a validator activates client validation, but no + // ClientValidationProvider is registered in DI, so the optional Services lookup returns + // null and the component renders nothing. + var editContext = new EditContext(new object()); + Activate(editContext); + RegisterField(editContext, "F"); + + var renderer = CreateRenderer(provider: null); + + var elementName = await RenderClientValidationDataAndGetCarrierElementName(renderer, editContext); + + Assert.Null(elementName); + } + + [Fact] + public async Task NoOp_WhenProviderReturnsNullFragment() + { + // The provider decides there is nothing to emit (e.g. none of the rendered fields are + // validated on the server) and returns null, so no carrier element is rendered. + var editContext = new EditContext(new object()); + Activate(editContext); + RegisterField(editContext, "F"); + + var renderer = CreateRenderer(provider: new FakeProvider(fragment: null)); + + var elementName = await RenderClientValidationDataAndGetCarrierElementName(renderer, editContext); + + Assert.Null(elementName); + } + + [Fact] + public async Task EditForm_RendersClientValidationDataInsideEditContextCascade() + { + // End-to-end at the render layer: must + // reach ClientValidationData, which renders the provider's fragment (the carrier element). + // + // This pins three things at once: + // (a) DataAnnotationsValidator successfully writes the activation flag. + // (b) ClientValidationData is inside the EditContext cascade scope so it resolves the + // cascading parameter (its [CascadingParameter] EditContext is populated). + // (c) Render order: validators and inputs inside ChildContent initialize before + // ClientValidationData renders, so the flag and the registered fields are observable. + var renderer = CreateRenderer(provider: new FakeProvider(CarrierFragment())); + + var host = new EditFormHostComponent { Model = new EditFormTestModel() }; + var hostId = renderer.AssignRootComponentId(host); + await renderer.RenderRootComponentAsync(hostId); + + // Walk every component frame in the batch and look for an element frame whose name + // matches the carrier. ClientValidationData is a nested component reached through + // CascadingValue, so a recursive walk is needed. + var found = WalkAllFramesForElement(renderer, CarrierElementName); + Assert.True(found, $"Expected to find <{CarrierElementName}> emitted by ClientValidationData inside EditForm."); + } + + // ---- Helpers ---- + + // Mirrors DataAnnotationsValidator: writes the activation flag keyed by the validator type. + private static void Activate(EditContext editContext) + => editContext.Properties[typeof(DataAnnotationsValidator)] = true; + + private static void RegisterField(EditContext editContext, string name) + => RenderedFieldRegistry.GetOrCreate(editContext).Register(editContext.Field(name), name); + + // A stand-in for the fragment a real ClientValidationProvider returns: emits the carrier element. + private static RenderFragment CarrierFragment() + => builder => + { + builder.OpenElement(0, CarrierElementName); + builder.AddAttribute(1, "data-rules", "{}"); + builder.CloseElement(); + }; + + private static TestRenderer CreateRenderer(ClientValidationProvider? provider) + { + var services = new ServiceCollection(); + if (provider is not null) + { + services.AddSingleton(provider); + } + // EditForm dependencies for the wiring test; ignored by the standalone component tests. + services.AddSingleton(); + services.AddAntiforgery(); + services.AddLogging(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService().State); + services.AddSingleton(); + return new TestRenderer(services.BuildServiceProvider()); + } + + private static async Task RenderClientValidationDataAndGetCarrierElementName( + TestRenderer renderer, EditContext editContext) + { + var host = new ClientValidationDataHostComponent { EditContext = editContext }; + var hostId = renderer.AssignRootComponentId(host); + await renderer.RenderRootComponentAsync(hostId); + + return WalkAllFramesForElement(renderer, CarrierElementName) ? CarrierElementName : null; + } + + private static bool WalkAllFramesForElement(TestRenderer renderer, string elementName) + { + var batch = renderer.Batches.Single(); + foreach (var componentFrame in batch.ReferenceFrames) + { + if (componentFrame.FrameType == RenderTreeFrameType.Component) + { + var frames = renderer.GetCurrentRenderTreeFrames(componentFrame.ComponentId); + for (var i = 0; i < frames.Count; i++) + { + if (frames.Array[i].FrameType == RenderTreeFrameType.Element + && frames.Array[i].ElementName == elementName) + { + return true; + } + } + } + } + return false; + } + + // Standalone host: renders just inside a CascadingValue, + // mirroring what EditForm does but without the rest of EditForm's surface. + private sealed class ClientValidationDataHostComponent : ComponentBase + { + public EditContext EditContext { get; set; } = default!; + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "IsFixed", true); + builder.AddComponentParameter(2, "Value", EditContext); + builder.AddComponentParameter(3, "ChildContent", (RenderFragment)(b => + { + b.OpenComponent(0); + b.CloseComponent(); + })); + builder.CloseComponent(); + } + } + + private sealed class EditFormHostComponent : ComponentBase + { + public EditFormTestModel Model { get; set; } = default!; + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, "Model", Model); + builder.AddComponentParameter(2, "ChildContent", (RenderFragment)(_ => childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.CloseComponent(); + + // A real input registers its field on the EditContext (gated on AssignedRenderMode + // is null, which holds in the test renderer), populating the registry that + // ClientValidationData reads before invoking the provider. + childBuilder.OpenComponent(1); + childBuilder.AddComponentParameter(2, "Value", Model.Name); + childBuilder.AddComponentParameter(3, "ValueExpression", (Expression>)(() => Model.Name)); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + } + } + + private sealed class EditFormTestModel + { + [Required] public string Name { get; set; } = ""; + } + + private sealed class FakeProvider : ClientValidationProvider + { + private readonly RenderFragment? _fragment; + public FakeProvider(RenderFragment? fragment) => _fragment = fragment; + public override RenderFragment? RenderClientValidationRules( + EditContext editContext, + IReadOnlyDictionary renderedFields) => _fragment; + } + + private sealed class NullFormValueMapper : IFormValueMapper + { + public bool CanMap(Type valueType, string scopeName, string? formName) => false; + public void Map(FormValueMappingContext context) { } + } +} diff --git a/src/Components/Web/test/Forms/InputBaseClientValidationTest.cs b/src/Components/Web/test/Forms/InputBaseClientValidationTest.cs deleted file mode 100644 index 5e238a781134..000000000000 --- a/src/Components/Web/test/Forms/InputBaseClientValidationTest.cs +++ /dev/null @@ -1,156 +0,0 @@ -// 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.ClientValidation; -using Microsoft.AspNetCore.Components.RenderTree; -using Microsoft.AspNetCore.Components.Test.Helpers; - -namespace Microsoft.AspNetCore.Components.Forms; - -public class InputBaseClientValidationTest -{ - private readonly TestRenderer _testRenderer = new TestRenderer(); - - [Fact] - public async Task InputBase_RendersDataValAttributes_WhenClientValidationServiceRegistered() - { - var model = new TestModel(); - var editContext = new EditContext(model); - editContext.Properties[typeof(IClientValidationService)] = new TestClientValidationService( - new Dictionary - { - ["data-val"] = "true", - ["data-val-required"] = "Generated required message.", - }); - - var rootComponent = new TestInputHostComponent - { - EditContext = editContext, - ValueExpression = () => model.Value, - }; - - var componentId = await RenderAndGetInputComponentIdAsync(rootComponent); - var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); - - AssertHasAttribute(frames, "data-val", "true"); - AssertHasAttribute(frames, "data-val-required", "Generated required message."); - } - - [Fact] - public async Task InputBase_DoesNotRenderDataValAttributes_WhenServiceNotRegistered() - { - var model = new TestModel(); - var editContext = new EditContext(model); // No IClientValidationService registered - - var rootComponent = new TestInputHostComponent - { - EditContext = editContext, - ValueExpression = () => model.Value, - }; - - var componentId = await RenderAndGetInputComponentIdAsync(rootComponent); - var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); - - AssertNoAttribute(frames, "data-val"); - } - - [Fact] - public async Task InputBase_DeveloperAdditionalAttributes_TakePrecedenceOverGenerated() - { - var model = new TestModel(); - var editContext = new EditContext(model); - editContext.Properties[typeof(IClientValidationService)] = new TestClientValidationService( - new Dictionary - { - ["data-val"] = "true", - ["data-val-required"] = "Generated message", - }); - - var rootComponent = new TestInputHostComponent - { - EditContext = editContext, - ValueExpression = () => model.Value, - AdditionalAttributes = new Dictionary - { - ["data-val-required"] = "Custom developer message", - }, - }; - - var componentId = await RenderAndGetInputComponentIdAsync(rootComponent); - var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); - - // Developer-specified attribute must win (first-wins merging in InputBase) - AssertHasAttribute(frames, "data-val-required", "Custom developer message"); - // Generated data-val=true should still be present (no developer override) - AssertHasAttribute(frames, "data-val", "true"); - } - - [Fact] - public async Task InputBase_NoDataValAttributes_WhenServiceReturnsEmpty() - { - var model = new TestModel(); - var editContext = new EditContext(model); - editContext.Properties[typeof(IClientValidationService)] = new TestClientValidationService( - new Dictionary()); - - var rootComponent = new TestInputHostComponent - { - EditContext = editContext, - ValueExpression = () => model.Value, - }; - - var componentId = await RenderAndGetInputComponentIdAsync(rootComponent); - var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); - - AssertNoAttribute(frames, "data-val"); - } - - // Helpers - - private async Task RenderAndGetInputComponentIdAsync(TestInputHostComponent hostComponent) - { - var hostComponentId = _testRenderer.AssignRootComponentId(hostComponent); - await _testRenderer.RenderRootComponentAsync(hostComponentId); - var batch = _testRenderer.Batches.Single(); - return batch.GetComponentFrames().Single().ComponentId; - } - - private static void AssertHasAttribute(ArrayRange frames, string attributeName, object expectedValue) - { - var match = frames.Array.Take(frames.Count).FirstOrDefault(f => - f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == attributeName); - - Assert.True(match.FrameType == RenderTreeFrameType.Attribute, - $"Expected attribute '{attributeName}' was not found in rendered output."); - Assert.Equal(expectedValue, match.AttributeValue); - } - - private static void AssertNoAttribute(ArrayRange frames, string attributeName) - { - Assert.DoesNotContain(frames.Array.Take(frames.Count), f => - f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == attributeName); - } - - /// - /// Test implementation of IClientValidationService that returns a fixed set of attributes - /// (or null) regardless of which field is requested. Lets us test InputBase's merge behavior - /// without depending on the internal DefaultClientValidationService. - /// - private sealed class TestClientValidationService : IClientValidationService - { - private readonly IReadOnlyDictionary _attributes; - - public TestClientValidationService(IReadOnlyDictionary attributes) - { - _attributes = attributes; - } - - public IReadOnlyDictionary GetClientValidationAttributes(FieldIdentifier fieldIdentifier) - => _attributes; - } - - private class TestModel - { - public string Value { get; set; } = ""; - } -} diff --git a/src/Components/test/E2ETest/ServerRenderingTests/AddValidationIntegrationTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/AddValidationIntegrationTest.cs index 81d5aa5e8647..3933d67ba78e 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/AddValidationIntegrationTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/AddValidationIntegrationTest.cs @@ -52,9 +52,16 @@ public void FormWithNestedValidation_Works() // Validation summary order is not guaranteed, so compare without ordering. Assert.Equal(expected.OrderBy(x => x), messages.OrderBy(x => x)); - // Individual field messages + // Individual field messages. + // TODO: In SSR form, ValidationMessage renders an empty placeholder
      + // for every field that has a ValidationMessage but no current error. + // This is done so that JS client-side validation can locate the slot for validation errors. + // So we filter out empty-text matches and assert only the fields that + // actually have a server validation error. If the empty-placeholder approach changes in + // the future to avoid the "validation-message" class, revisit this filter. var individual = Browser.FindElements(By.CssSelector(".mb-3 > .validation-message")) .Select(element => element.Text) + .Where(text => !string.IsNullOrEmpty(text)) .ToList(); Assert.Equal(expected, individual); diff --git a/src/Components/test/E2ETest/ServerRenderingTests/ClientValidation/ClientValidationAttributesTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/ClientValidation/ClientValidationAttributesTest.cs deleted file mode 100644 index 414bb7cfac0d..000000000000 --- a/src/Components/test/E2ETest/ServerRenderingTests/ClientValidation/ClientValidationAttributesTest.cs +++ /dev/null @@ -1,176 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -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; - -namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests.ClientValidation; - -// E2E smoke coverage for individual built-in validators, custom validator -// registration, radio groups, and server-rendered message cleanup. -// Per-validator exhaustive coverage lives in the Jest unit tests on -// CoreValidators.test.ts; the tests here verify the integration path. -[CollectionDefinition(nameof(ClientValidationBasicTest), DisableParallelization = true)] -public class ClientValidationAttributesTest : ClientValidationTestBase -{ - public ClientValidationAttributesTest( - BrowserFixture browserFixture, - BasicTestAppServerSiteFixture> serverFixture, - ITestOutputHelper output) - : base(browserFixture, serverFixture, output) - { - } - - [Fact] - public void RangeRejectsOutOfRangeValue() - { - NavigateToClientValidationPage("all-validators"); - - Browser.Exists(By.Id("age")).SendKeys("999"); - Browser.Exists(By.Id("submit")).Click(); - - Browser.Equal("Age must be between 1 and 150.", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='Age']")).Text); - } - - [Fact] - public void RegexRejectsNonMatchingValue() - { - NavigateToClientValidationPage("all-validators"); - - Browser.Exists(By.Id("zipcode")).SendKeys("not-a-zip"); - Browser.Exists(By.Id("submit")).Click(); - - Browser.Equal("Invalid zip code.", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='ZipCode']")).Text); - } - - [Fact] - public void EmailRejectsInvalidEmail() - { - NavigateToClientValidationPage("all-validators"); - - Browser.Exists(By.Id("email")).SendKeys("not-an-email"); - Browser.Exists(By.Id("submit")).Click(); - - Browser.Equal("Invalid email address.", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='Email']")).Text); - } - - [Fact] - public void EqualToRejectsMismatchedPasswords() - { - NavigateToClientValidationPage("all-validators"); - - Browser.Exists(By.Id("password")).SendKeys("secret123"); - Browser.Exists(By.Id("confirm")).SendKeys("differentValue"); - Browser.Exists(By.Id("submit")).Click(); - - Browser.Equal("Passwords must match.", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='ConfirmPassword']")).Text); - } - - [Fact] - public void EqualToAcceptsMatchingPasswords() - { - NavigateToClientValidationPage("all-validators"); - - Browser.Exists(By.Id("password")).SendKeys("secret123"); - Browser.Exists(By.Id("confirm")).SendKeys("secret123"); - Browser.Exists(By.Id("submit")).Click(); - - // The ConfirmPassword field has no error; the form is still invalid - // because of the Anchor field, but ConfirmPassword's slot is empty. - Browser.Equal("", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='ConfirmPassword']")).Text); - } - - [Fact] - public void FileExtensionsRejectsDisallowedExtension() - { - NavigateToClientValidationPage("all-validators"); - - Browser.Exists(By.Id("avatar")).SendKeys("malware.exe"); - Browser.Exists(By.Id("submit")).Click(); - - Browser.Equal("Only image files are allowed.", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='Avatar']")).Text); - } - - [Fact] - public void RequiredRadioGroup_ShowsErrorWhenNoneSelected() - { - NavigateToClientValidationPage("radio-group"); - - Browser.Exists(By.Id("submit")).Click(); - - Browser.Equal("Please select a color.", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='Color']")).Text); - } - - [Fact] - public void RequiredRadioGroup_ClearsErrorWhenOneSelected() - { - NavigateToClientValidationPage("radio-group"); - - Browser.Exists(By.Id("submit")).Click(); - Browser.Equal("Please select a color.", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='Color']")).Text); - - Browser.Exists(By.Id("color-green")).Click(); - Browser.Exists(By.Id("submit")).Click(); - - Browser.Equal("", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='Color']")).Text); - } - - [Fact] - public void CustomValidatorRejectsInvalidInput() - { - NavigateToClientValidationPage("custom-validator"); - Browser.Exists(By.Id("custom-validator-ready")); - - Browser.Exists(By.Id("code")).SendKeys("XYZ-123"); - Browser.Exists(By.Id("submit")).Click(); - - Browser.Equal("Code must start with 'ABC-'.", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='Code']")).Text); - } - - [Fact] - public void CustomValidatorAcceptsValidInput() - { - NavigateToClientValidationPage("custom-validator"); - Browser.Exists(By.Id("custom-validator-ready")); - - Browser.Exists(By.Id("code")).SendKeys("ABC-123"); - Browser.Exists(By.Id("submit")).Click(); - - Browser.Equal("", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='Code']")).Text); - } - - [Fact] - public void JsRemovesSiblingServerErrorsWhenFieldBecomesValid() - { - NavigateToClientValidationPage("server-rendered-messages"); - - // Initially: the Name field has a primary message + one extra sibling - // (server-rendered). Both are present in the DOM. - Browser.True(() => - Browser.FindElements(By.CssSelector("#test-form > div:first-child > .validation-message")).Count == 2); - - // Fill Name and trigger blur to validate - Browser.Exists(By.Id("name")).SendKeys("Alice"); - Browser.Exists(By.Id("name")).SendKeys(Keys.Tab); - - // Sibling cleanup: only the primary [data-valmsg-for] message remains. - Browser.True(() => - Browser.FindElements(By.CssSelector("#test-form > div:first-child > .validation-message")).Count == 1); - } -} diff --git a/src/Components/test/E2ETest/ServerRenderingTests/ClientValidation/ClientValidationBasicTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/ClientValidation/ClientValidationBasicTest.cs deleted file mode 100644 index 5552c148e87e..000000000000 --- a/src/Components/test/E2ETest/ServerRenderingTests/ClientValidation/ClientValidationBasicTest.cs +++ /dev/null @@ -1,183 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -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 OpenQA.Selenium.Support.UI; -using TestServer; -using Xunit.Abstractions; - -namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests.ClientValidation; - -// E2E coverage for the JS client-side validation pipeline against a Blazor SSR page -// with raw
      / markup. The .NET-side rendering of data-val-* -// attributes via DataAnnotationsValidator/InputBase lives in a separate PR; these -// tests exercise the JS library's behaviour against markup that matches what the -// .NET side will emit once both PRs land. -[CollectionDefinition(nameof(ClientValidationBasicTest), DisableParallelization = true)] -public class ClientValidationBasicTest : ClientValidationTestBase -{ - public ClientValidationBasicTest( - BrowserFixture browserFixture, - BasicTestAppServerSiteFixture> serverFixture, - ITestOutputHelper output) - : base(browserFixture, serverFixture, output) - { - } - - protected override void InitializeAsyncCore() - { - base.InitializeAsyncCore(); - NavigateToClientValidationPage("basic-validation"); - // Reset client-side state captured across test runs. - ((IJavaScriptExecutor)Browser).ExecuteScript("localStorage.removeItem('lastValidation');"); - } - - [Fact] - public void SubmittingEmptyFormShowsRequiredErrors() - { - Browser.Exists(By.Id("submit")).Click(); - - Browser.Equal("The Name field is required.", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='Name']")).Text); - Browser.Equal("The Email field is required.", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='Email']")).Text); - Browser.Equal("Password is required.", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='Password']")).Text); - } - - [Fact] - public void FillingRequiredFieldsAndSubmittingValidates() - { - Browser.Exists(By.Id("name")).SendKeys("Alice"); - Browser.Exists(By.Id("email")).SendKeys("alice@example.com"); - Browser.Exists(By.Id("password")).SendKeys("password123"); - Browser.Exists(By.Id("agree")).Click(); - new SelectElement(Browser.Exists(By.Id("category"))).SelectByValue("A"); - - Browser.Exists(By.Id("submit")).Click(); - - // localStorage value persists across the post-submit page reload that - // happens when JS allows the submit through. - Browser.Equal("valid:true;errors:0", - () => (string)((IJavaScriptExecutor)Browser).ExecuteScript( - "return localStorage.getItem('lastValidation');")); - } - - [Fact] - public void InvalidFieldGetsAriaInvalidAndAriaDescribedBy() - { - Browser.Exists(By.Id("submit")).Click(); - - Browser.Equal("true", - () => Browser.Exists(By.Id("name")).GetAttribute("aria-invalid")); - Browser.True( - () => !string.IsNullOrEmpty(Browser.Exists(By.Id("name")).GetAttribute("aria-describedby"))); - } - - [Fact] - public void ValidFieldHasNoAriaInvalid() - { - var name = Browser.Exists(By.Id("name")); - name.SendKeys("Alice"); - name.SendKeys(Keys.Tab); // commit change - - Browser.True(() => Browser.Exists(By.Id("name")).GetAttribute("aria-invalid") is null); - } - - [Fact] - public void SummaryUpdatesAfterInvalidSubmit() - { - Browser.Exists(By.Id("submit")).Click(); - - Browser.True(() => - Browser.FindElements(By.CssSelector("[data-valmsg-summary='true'] li")).Count >= 4); - } - - [Fact] - public void SummaryClearsAfterValidSubmit() - { - Browser.Exists(By.Id("submit")).Click(); - Browser.True(() => - Browser.FindElements(By.CssSelector("[data-valmsg-summary='true'] li")).Count > 0); - - Browser.Exists(By.Id("name")).SendKeys("Alice"); - Browser.Exists(By.Id("email")).SendKeys("alice@example.com"); - Browser.Exists(By.Id("password")).SendKeys("password123"); - Browser.Exists(By.Id("agree")).Click(); - new SelectElement(Browser.Exists(By.Id("category"))).SelectByValue("A"); - Browser.Exists(By.Id("submit")).Click(); - - Browser.Equal("valid:true;errors:0", - () => (string)((IJavaScriptExecutor)Browser).ExecuteScript( - "return localStorage.getItem('lastValidation');")); - } - - [Fact] - public void ResetClearsValidationErrorsAndCssClasses() - { - Browser.Exists(By.Id("submit")).Click(); - Browser.Exists(By.CssSelector(".input-validation-error")); - - Browser.Exists(By.Id("reset")).Click(); - - Browser.DoesNotExist(By.CssSelector(".input-validation-error")); - Browser.DoesNotExist(By.CssSelector(".input-validation-valid")); - } - - [Fact] - public void NovalidateAttributeAddedToTrackedForm() - { - // Verified by the InitializeAsyncCore precondition; reasserted here for - // explicit documentation of the expected behaviour. - Browser.True(() => Browser.Exists(By.Id("test-form")).GetAttribute("novalidate") != null); - } - - [Fact] - public void ValidationCompleteEventDispatchedOnInvalidSubmit() - { - Browser.Exists(By.Id("submit")).Click(); - - // Form is invalid so JS prevents default; page does not reload, event-log - // div is observable directly. (localStorage is also written, but use the - // div here as a separate signal.) - Browser.Contains("valid:false", () => Browser.Exists(By.Id("event-log")).Text); - } - - [Fact] - public void CheckboxRequiredValidation_RejectsUnchecked() - { - Browser.Exists(By.Id("submit")).Click(); - Browser.Equal("You must agree to the terms.", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='Agree']")).Text); - } - - [Fact] - public void SelectRequiredValidation_RejectsUnselected() - { - Browser.Exists(By.Id("submit")).Click(); - Browser.Equal("Please select a category.", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='Category']")).Text); - } - - [Fact] - public void MaxlengthShowsErrorWhenExceeded() - { - Browser.Exists(By.Id("bio")).SendKeys(new string('x', 101)); - Browser.Exists(By.Id("submit")).Click(); - Browser.Equal("Bio must be at most 100 characters.", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='Bio']")).Text); - } - - [Fact] - public void LengthMinMaxShowsErrorWhenTooShort() - { - Browser.Exists(By.Id("password")).SendKeys("short"); - Browser.Exists(By.Id("submit")).Click(); - Browser.Equal("Password must be between 8 and 50 characters.", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='Password']")).Text); - } -} diff --git a/src/Components/test/E2ETest/ServerRenderingTests/ClientValidation/ClientValidationEditFormTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/ClientValidation/ClientValidationEditFormTest.cs deleted file mode 100644 index 3914ffcb10d1..000000000000 --- a/src/Components/test/E2ETest/ServerRenderingTests/ClientValidation/ClientValidationEditFormTest.cs +++ /dev/null @@ -1,116 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -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; - -namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests.ClientValidation; - -// Verifies that DefaultClientValidationService produces correct data-val-* attributes when -// rendering through the full .NET pipeline (EditForm + DataAnnotationsValidator + InputBase). -// No IValidationLocalizer configured — this pins the non-localized baseline output for -// every supported [DataAnnotations] attribute. Localized output is covered separately by -// ClientValidationLocalizationTest. -[CollectionDefinition(nameof(ClientValidationBasicTest), DisableParallelization = true)] -public class ClientValidationEditFormTest : ClientValidationTestBase -{ - public ClientValidationEditFormTest( - BrowserFixture browserFixture, - BasicTestAppServerSiteFixture> serverFixture, - ITestOutputHelper output) - : base(browserFixture, serverFixture, output) - { - } - - [Fact] - public void Required_EmitsDataValRequired_WithDisplayNameInMessage() - { - NavigateToEditFormValidationPage(); - - // [Required] + [Display(Name="Full Name")] — message uses the localized display name - // resolved by DefaultClientValidationService.ResolveDisplayName. - Assert.Equal("The Full Name field is required.", GetDataValAttribute("name", "data-val-required")); - } - - [Fact] - public void Required_NoDisplayAttribute_UsesPropertyNameInMessage() - { - NavigateToEditFormValidationPage(); - - // [Required] without [Display] — message falls back to the property name. - Assert.Equal("The Password field is required.", GetDataValAttribute("password", "data-val-required")); - } - - [Fact] - public void StringLength_EmitsLengthAttributesWithMinAndMax() - { - NavigateToEditFormValidationPage(); - - var bio = Browser.Exists(By.Id("bio")); - Assert.NotNull(bio.GetAttribute("data-val-length")); - Assert.Equal("10", bio.GetAttribute("data-val-length-min")); - Assert.Equal("500", bio.GetAttribute("data-val-length-max")); - } - - [Fact] - public void RegularExpression_EmitsRegexAttributeAndPattern() - { - NavigateToEditFormValidationPage(); - - var zip = Browser.Exists(By.Id("zipcode")); - Assert.NotNull(zip.GetAttribute("data-val-regex")); - Assert.Equal(@"\d{5}", zip.GetAttribute("data-val-regex-pattern")); - } - - [Fact] - public void Url_EmitsDataValUrl() - { - NavigateToEditFormValidationPage(); - - var website = Browser.Exists(By.Id("website")); - Assert.NotNull(website.GetAttribute("data-val-url")); - } - - [Fact] - public void Compare_EmitsEqualToAttributeAndOther() - { - NavigateToEditFormValidationPage(); - - var confirm = Browser.Exists(By.Id("confirmpassword")); - Assert.NotNull(confirm.GetAttribute("data-val-equalto")); - // The "*." prefix is the JS equalto convention for resolving the other field - // relative to the current field's name prefix. - Assert.Equal("*.Password", confirm.GetAttribute("data-val-equalto-other")); - } - - [Fact] - public void TrackedField_HasDataValTrueMarker() - { - NavigateToEditFormValidationPage(); - - // Every input with at least one validation attribute gets data-val="true". - Assert.Equal("true", Browser.Exists(By.Id("name")).GetAttribute("data-val")); - Assert.Equal("true", Browser.Exists(By.Id("zipcode")).GetAttribute("data-val")); - } - - [Fact] - public void FormHasNovalidate_AfterJsValidationLibraryScans() - { - // Sanity check: the JS lib scans the rendered form and applies novalidate. - // This is also what NavigateToClientValidationPage's expectTrackedForm waits for. - NavigateToEditFormValidationPage(); - - Browser.Exists(By.CssSelector("form[novalidate]")); - } - - private void NavigateToEditFormValidationPage() - => NavigateToClientValidationPage("editform-validation"); - - private string GetDataValAttribute(string inputId, string attributeName) - => Browser.Exists(By.Id(inputId)).GetAttribute(attributeName); -} diff --git a/src/Components/test/E2ETest/ServerRenderingTests/ClientValidation/ClientValidationLocalizationTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/ClientValidation/ClientValidationLocalizationTest.cs deleted file mode 100644 index 700d163971e8..000000000000 --- a/src/Components/test/E2ETest/ServerRenderingTests/ClientValidation/ClientValidationLocalizationTest.cs +++ /dev/null @@ -1,93 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -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; - -namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests.ClientValidation; - -// Verifies that DefaultClientValidationService renders culture-appropriate data-val-* attributes -// when a server-side IValidationLocalizer is configured. The TestServer registers an inline -// IValidationLocalizer that returns hardcoded translations based on CurrentUICulture, which is -// set per-request via the ?culture= query string (see RazorComponentEndpointsStartup). -[CollectionDefinition(nameof(ClientValidationBasicTest), DisableParallelization = true)] -public class ClientValidationLocalizationTest : ClientValidationTestBase -{ - public ClientValidationLocalizationTest( - BrowserFixture browserFixture, - BasicTestAppServerSiteFixture> serverFixture, - ITestOutputHelper output) - : base(browserFixture, serverFixture, output) - { - } - - [Fact] - public void DefaultCulture_UsesLiteralAttributeValuesAndDefaultMessages() - { - // No ?culture= → CurrentUICulture stays at the default (en-US). The localizer's lookup - // misses for English keys → falls back to the literal display name and the attribute's - // default formatted message ("RequiredKey" / "RangeKey" — they are the literal - // ErrorMessage values, not real keys; this asserts the no-localization-hit path). - NavigateToClientValidationPage("localized-validation"); - - Assert.Equal("RequiredKey", GetDataValAttribute("email", "data-val-required")); - Assert.Equal("RangeKey", GetDataValAttribute("age", "data-val-range")); - } - - [Fact] - public void FrenchCulture_LocalizesDisplayNameAndErrorMessage() - { - Navigate("subdir/forms/client-validation/localized-validation?culture=fr"); - Browser.Exists(By.Id("blazor-started")); - Browser.Exists(By.Id("page-title")); - Browser.Exists(By.CssSelector("form[novalidate]")); - - // Display name "Email Address" → "Adresse e-mail" (fr); template "Le champ {0} est requis (fr)" - // is formatted with the localized display name. - Assert.Equal("Le champ Adresse e-mail est requis (fr)", GetDataValAttribute("email", "data-val-required")); - // Range template gets {0}=display, {1}=Min, {2}=Max substituted by our test localizer. - Assert.Equal("Le champ Âge doit être entre 18 et 120 (fr)", GetDataValAttribute("age", "data-val-range")); - } - - [Fact] - public void GermanCulture_LocalizesDisplayNameAndErrorMessage() - { - Navigate("subdir/forms/client-validation/localized-validation?culture=de"); - Browser.Exists(By.Id("blazor-started")); - Browser.Exists(By.Id("page-title")); - Browser.Exists(By.CssSelector("form[novalidate]")); - - Assert.Equal("Das Feld E-Mail-Adresse ist erforderlich (de)", GetDataValAttribute("email", "data-val-required")); - Assert.Equal("Das Feld Alter muss zwischen 18 und 120 liegen (de)", GetDataValAttribute("age", "data-val-range")); - } - - [Fact] - public void DifferentCulturesProduceDifferentOutput_NoCachePoisoning() - { - // Regression guard: visit French first, then German on the SAME server (which holds the - // singleton DefaultClientValidationService). The reflection cache is shared across - // requests but the per-call display-name and error-message resolution must respect the - // current request's CultureInfo.CurrentUICulture. - Navigate("subdir/forms/client-validation/localized-validation?culture=fr"); - Browser.Exists(By.Id("blazor-started")); - Browser.Exists(By.CssSelector("form[novalidate]")); - var frRequired = GetDataValAttribute("email", "data-val-required"); - - Navigate("subdir/forms/client-validation/localized-validation?culture=de"); - Browser.Exists(By.Id("blazor-started")); - Browser.Exists(By.CssSelector("form[novalidate]")); - var deRequired = GetDataValAttribute("email", "data-val-required"); - - Assert.NotEqual(frRequired, deRequired); - Assert.Contains("(fr)", frRequired); - Assert.Contains("(de)", deRequired); - } - - private string GetDataValAttribute(string inputId, string attributeName) - => Browser.Exists(By.Id(inputId)).GetAttribute(attributeName); -} diff --git a/src/Components/test/E2ETest/ServerRenderingTests/ClientValidation/ClientValidationScenariosTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/ClientValidation/ClientValidationScenariosTest.cs deleted file mode 100644 index d6a43b669833..000000000000 --- a/src/Components/test/E2ETest/ServerRenderingTests/ClientValidation/ClientValidationScenariosTest.cs +++ /dev/null @@ -1,201 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -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; - -namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests.ClientValidation; - -// E2E coverage for advanced JS validation scenarios: trigger overrides, skipped -// elements, multi-form independence, dynamic content, and untracked forms. -[CollectionDefinition(nameof(ClientValidationBasicTest), DisableParallelization = true)] -public class ClientValidationScenariosTest : ClientValidationTestBase -{ - public ClientValidationScenariosTest( - BrowserFixture browserFixture, - BasicTestAppServerSiteFixture> serverFixture, - ITestOutputHelper output) - : base(browserFixture, serverFixture, output) - { - } - - [Fact] - public void DataValeventSubmit_NoValidationOnChangeOrInput() - { - NavigateToClientValidationPage("timing"); - - var field = Browser.Exists(By.Id("submit-only")); - field.Click(); - field.SendKeys(Keys.Tab); // blur - - // Should NOT have triggered validation (per data-valevent="submit") - Browser.Equal("", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='SubmitOnly']")).Text); - - // Submit triggers it - Browser.Exists(By.Id("submit")).Click(); - Browser.Equal("SubmitOnly is required.", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='SubmitOnly']")).Text); - } - - [Fact] - public void DataValeventInput_ValidatesEagerlyOnPristineForm() - { - NavigateToClientValidationPage("timing"); - - var field = Browser.Exists(By.Id("input-eager")); - // Type a character to trigger 'input' event, then clear it via backspace - field.SendKeys("x"); - field.SendKeys(Keys.Backspace); - - // After clearing (back to empty), should be invalid - Browser.Equal("InputEager is required.", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='InputEager']")).Text); - } - - [Fact] - public void HiddenDisabledAndDisplayNoneFieldsAreSkipped() - { - NavigateToClientValidationPage("formnovalidate"); - - // Fill only Name; the form has 3 other "required" fields that are hidden, - // disabled, or display:none and should all be skipped during validation. - Browser.Exists(By.Id("name")).SendKeys("Alice"); - Browser.Exists(By.Id("btn-submit")).Click(); - - Browser.Contains("valid:true", () => Browser.Exists(By.Id("event-log")).Text); - } - - [Fact] - public void FormnovalidateButtonBypassesValidation() - { - NavigateToClientValidationPage("formnovalidate"); - - // Click the formnovalidate button without filling anything; validation - // is bypassed entirely (no validationcomplete event dispatched, no errors). - Browser.Exists(By.Id("btn-draft")).Click(); - - // No error message text in the Name field's slot. - Browser.Equal("", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='Name']")).Text); - } - - [Fact] - public void ValidationFailureBlocksDownstreamSubmitHandler() - { - // Regression coverage for dotnet/aspnetcore#66732: when validation fails, the - // capture-phase interceptor must call stopPropagation(), which prevents a - // downstream form-level submit handler (like the Identity passkey custom element) - // from running. - NavigateToClientValidationPage("formnovalidate"); - - // Standard submit button with the required Name field empty. - Browser.Exists(By.Id("btn-submit")).Click(); - - Browser.Contains("valid:false", () => Browser.Exists(By.Id("event-log")).Text); - Browser.Equal("", () => Browser.Exists(By.Id("downstream-log")).Text); - } - - [Fact] - public void FormnovalidateButtonAllowsDownstreamSubmitHandler() - { - // Regression coverage for dotnet/aspnetcore#66732: a formnovalidate submit button - // lets the validation interceptor bail out early so a downstream form-level submit - // handler (like the Identity passkey custom element) still runs on an invalid form. - NavigateToClientValidationPage("formnovalidate"); - - // formnovalidate button with the required Name field empty. - Browser.Exists(By.Id("btn-draft")).Click(); - - Browser.Equal("downstream-ran", () => Browser.Exists(By.Id("downstream-log")).Text); - Browser.Equal("", () => Browser.Exists(By.Id("event-log")).Text); - } - - [Fact] - public void UntrackedFormHasNoNovalidateAttribute() - { - // The 'no-validation' page intentionally has no [data-val=true] elements, - // so the JS library leaves the form alone and never adds [novalidate]. - // Skip the submit interceptor too, so the page's "untracked forms post - // normally" behaviour is not masked from any future assertions. - NavigateToClientValidationPage("no-validation", expectTrackedForm: false, interceptSubmit: false); - - Browser.True(() => Browser.Exists(By.Id("plain-form")).GetAttribute("novalidate") is null); - } - - [Fact] - public void SubmittingOneFormDoesNotValidateOtherForm() - { - NavigateToClientValidationPage("multiple-forms"); - Browser.Exists(By.CssSelector("#form-a[novalidate]")); - Browser.Exists(By.CssSelector("#form-b[novalidate]")); - - Browser.Exists(By.Id("submit-a")).Click(); - - Browser.Equal("Name A is required.", - () => Browser.Exists(By.CssSelector("#form-a [data-valmsg-for='Name']")).Text); - Browser.Equal("", - () => Browser.Exists(By.CssSelector("#form-b [data-valmsg-for='Name']")).Text); - } - - [Fact] - public void EachFormHasIndependentSummary() - { - NavigateToClientValidationPage("multiple-forms"); - - Browser.Exists(By.Id("submit-a")).Click(); - - Browser.True(() => - Browser.FindElements(By.CssSelector("#form-a [data-valmsg-summary='true'] li")).Count > 0); - Browser.Equal(0, - () => Browser.FindElements(By.CssSelector("#form-b [data-valmsg-summary='true'] li")).Count); - } - - [Fact] - public void DynamicallyAddedFieldsValidatedAfterScanRules() - { - NavigateToClientValidationPage("dynamic-content"); - - Browser.Exists(By.Id("add-field")).Click(); - Browser.Exists(By.Id("dyn")); - - Browser.Exists(By.Id("submit")).Click(); - - Browser.Equal("Dyn is required.", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='Dyn']")).Text); - } - - [Fact] - public void RemovedFieldsCleanedUpOnReScan() - { - NavigateToClientValidationPage("dynamic-content"); - - Browser.Exists(By.Id("add-field")).Click(); - Browser.Exists(By.Id("dyn")); - Browser.Exists(By.Id("remove-field")).Click(); - Browser.DoesNotExist(By.Id("dyn")); - - // Submitting now should fail only on Name, not on Dyn (which no longer exists). - Browser.Exists(By.Id("submit")).Click(); - - Browser.Equal("Name is required.", - () => Browser.Exists(By.CssSelector("[data-valmsg-for='Name']")).Text); - } - - [Fact] - public void FirstInvalidFieldFocusedOnSubmit() - { - NavigateToClientValidationPage("basic-validation"); - - Browser.Exists(By.Id("submit")).Click(); - - // First invalid field is Name; engine focuses it after validateForm. - Browser.Equal("name", - () => Browser.SwitchTo().ActiveElement().GetAttribute("id")); - } -} diff --git a/src/Components/test/E2ETest/ServerRenderingTests/ClientValidation/ClientValidationTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/ClientValidation/ClientValidationTest.cs new file mode 100644 index 000000000000..e9d598e0b82e --- /dev/null +++ b/src/Components/test/E2ETest/ServerRenderingTests/ClientValidation/ClientValidationTest.cs @@ -0,0 +1,275 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using System.Text.Json; +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; + +namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests.ClientValidation; + +// E2E coverage for the reworked client-side validation pipeline: a real Blazor SSR +// EditForm renders a single carrier element whose JSON +// payload the JS engine ingests, then validates user input in the browser before submit. +// +// This layer owns ONLY what requires a real browser + real Blazor SSR + the real JS engine +// wired together end to end. Per-attribute rule emission is owned by the Endpoints provider +// integration tests; the JS engine's validator/cleanup/ARIA behaviour is owned by the Jest +// suite. See rework-client-validation-tests.md section 3.4. +// +// Field names are prefixed by the [SupplyParameterFromForm] property name (e.g. the page's +// "Form" property yields rendered names "Form.Name", "Form.Email", ...). The input name, +// the [data-valmsg-for] slot and the carrier payload all use that same prefixed name. +public class ClientValidationTest : ClientValidationTestBase +{ + public ClientValidationTest( + BrowserFixture browserFixture, + BasicTestAppServerSiteFixture> serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + [Fact] + public void BasicForm_InvalidSubmit_DisplaysErrors_ValidSubmit_Clears() + { + NavigateToClientValidationPage("basic-validation"); + + // Empty submit: the three [Required] fields report errors. [Compare]/[EmailAddress]/ + // [StringLength] do not fire on empty values. + Browser.Exists(By.Id("submit")).Click(); + Browser.Equal("Name is required.", () => FieldMessage("Form.Name")); + Browser.Equal("Email is required.", () => FieldMessage("Form.Email")); + Browser.Equal("Password is required.", () => FieldMessage("Form.Password")); + + // Fire one non-required rule to test integration end-to-end. + Browser.Exists(By.Id("name")).SendKeys("Alice"); + Browser.Exists(By.Id("email")).SendKeys("not-an-email"); + Browser.Exists(By.Id("password")).SendKeys("longenoughpassword"); + Browser.Exists(By.Id("confirmpassword")).SendKeys("longenoughpassword"); + Browser.Exists(By.Id("submit")).Click(); + + Browser.Equal("Email is not valid.", () => FieldMessage("Form.Email")); + Browser.Equal("", () => FieldMessage("Form.Name")); + Browser.Equal("", () => FieldMessage("Form.Password")); + Browser.Equal("", () => FieldMessage("Form.ConfirmPassword")); + + // Fix the email and submit: all errors clear and the form reports valid. + ReplaceText(By.Id("email"), "alice@example.com"); + Browser.Exists(By.Id("submit")).Click(); + + Browser.Equal("valid:true;errors:0", () => Browser.Exists(By.Id("event-log")).Text); + Browser.Equal("", () => FieldMessage("Form.Email")); + } + + [Fact] + public void CarrierElement_IsRenderedInsideForm_WithExpectedJsonShape() + { + NavigateToClientValidationPage("basic-validation"); + + // Exactly one carrier, and it is a descendant of the form. + Assert.Single(Browser.FindElements(By.CssSelector("form blazor-client-validation-data"))); + + var json = (string)((IJavaScriptExecutor)Browser).ExecuteScript( + "return document.querySelector('blazor-client-validation-data').getAttribute('data-rules');"); + + using var document = JsonDocument.Parse(json); + var fields = document.RootElement.GetProperty("fields").EnumerateArray().ToList(); + + var fieldNames = fields.Select(f => f.GetProperty("name").GetString()).ToList(); + Assert.Contains("Form.Name", fieldNames); + Assert.Contains("Form.Email", fieldNames); + Assert.Contains("Form.Password", fieldNames); + + // The Email field carries both a 'required' and an 'email' rule. + var emailRules = fields + .Single(f => f.GetProperty("name").GetString() == "Form.Email") + .GetProperty("rules").EnumerateArray() + .Select(r => r.GetProperty("name").GetString()) + .ToList(); + Assert.Contains("required", emailRules); + Assert.Contains("email", emailRules); + } + + // Tests checking that the JS adapter layer supports enhanced navigation correctly. + // Enhanced navigation updates the page by DOM morphing, which reuses the carrier element + // (its connectedCallback does not re-fire) and strips the JS-added novalidate. + // These tests cover the transitions: form->form->back (form->form is its first leg), + // form->no-form->form, and no-form->form. + + [Fact] + public void EnhancedNavigation_FormToFormAndBack_EachValidatesOwnRules() + { + NavigateToClientValidationPage("enhanced-nav-a"); + MarkEnhancedNavProbe(); + + // A -> B + Browser.Exists(By.Id("go-to-b")).Click(); + Browser.Equal("Enhanced navigation form B", () => Browser.Exists(By.Id("page-title")).Text); + Browser.Exists(By.Id("submit")).Click(); + Browser.Equal("Beta is required.", () => FieldMessage("Form.Beta")); + + // B -> A: the reused carrier's payload changes back to A's rules. + Browser.Exists(By.Id("go-to-a")).Click(); + Browser.Equal("Enhanced navigation form A", () => Browser.Exists(By.Id("page-title")).Text); + AssertWasEnhancedNavigation(); + Browser.Exists(By.CssSelector("form[novalidate]")); + Browser.Exists(By.Id("submit")).Click(); + Browser.Equal("Alpha is required.", () => FieldMessage("Form.Alpha")); + } + + [Fact] + public void EnhancedNavigation_FormToNoFormAndBack_RevalidatesForm() + { + NavigateToClientValidationPage("enhanced-nav-a"); + MarkEnhancedNavProbe(); + + // A -> no-form page: the carrier is removed; nothing should remain tracked. + Browser.Exists(By.Id("go-to-noform")).Click(); + Browser.Equal("Enhanced navigation no-form page", () => Browser.Exists(By.Id("page-title")).Text); + AssertWasEnhancedNavigation(); + Browser.DoesNotExist(By.CssSelector("blazor-client-validation-data")); + + // no-form -> A: the form must be (re-)registered even though the service already exists. + Browser.Exists(By.Id("go-to-a")).Click(); + Browser.Equal("Enhanced navigation form A", () => Browser.Exists(By.Id("page-title")).Text); + Browser.Exists(By.CssSelector("form[novalidate]")); + Browser.Exists(By.Id("submit")).Click(); + Browser.Equal("Alpha is required.", () => FieldMessage("Form.Alpha")); + } + + [Fact] + public void EnhancedNavigation_NoFormToForm_RegistersValidation() + { + // Start on a page with no carrier (the service is not created on this page). + Navigate("subdir/forms/client-validation/enhanced-nav-noform"); + Browser.Exists(By.Id("blazor-started")); + Browser.Equal("Enhanced navigation no-form page", () => Browser.Exists(By.Id("page-title")).Text); + MarkEnhancedNavProbe(); + + // no-form -> form A: the service is created on first carrier sighting and registers A. + Browser.Exists(By.Id("go-to-a")).Click(); + Browser.Equal("Enhanced navigation form A", () => Browser.Exists(By.Id("page-title")).Text); + AssertWasEnhancedNavigation(); + Browser.Exists(By.CssSelector("form[novalidate]")); + + ((IJavaScriptExecutor)Browser).ExecuteScript( + "document.addEventListener('submit', function (e) { e.preventDefault(); }, false);"); + Browser.Exists(By.Id("submit")).Click(); + Browser.Equal("Alpha is required.", () => FieldMessage("Form.Alpha")); + } + + [Fact] + public void StreamingRendering_FormDeliveredViaStream_RegistersValidation() + { + // The form is rendered after a streaming delay, so it arrives via a streamed DOM update + // (the same merge + 'enhancedload' path as enhanced navigation). The helper waits for + // form[novalidate], which only appears once the streamed form is registered. + NavigateToClientValidationPage("streaming-validation"); + + Browser.Exists(By.Id("submit")).Click(); + Browser.Equal("Gamma is required.", () => FieldMessage("Form.Gamma")); + } + + [Fact] + public void MultipleForms_OnSamePage_ValidateIndependently() + { + NavigateToClientValidationPage("multiple-forms"); + + // Submitting form A validates only form A; form B stays untouched. + Browser.Exists(By.Id("submit-a")).Click(); + Browser.Equal("Name A is required.", + () => Browser.Exists(By.CssSelector("#form-a [data-valmsg-for='FormA.Name']")).Text); + Browser.Equal("", + () => Browser.Exists(By.CssSelector("#form-b [data-valmsg-for='FormB.Name']")).Text); + + // Submitting form B now validates only form B. + Browser.Exists(By.Id("submit-b")).Click(); + Browser.Equal("Name B is required.", + () => Browser.Exists(By.CssSelector("#form-b [data-valmsg-for='FormB.Name']")).Text); + } + + [Fact] + public void CustomValidator_RegisteredViaAddValidator_FiresFromEmittedRule() + { + NavigateToClientValidationPage("custom-validator"); + + // Wait until the page has registered the 'startswith' JS validator. + Browser.Exists(By.Id("custom-validator-ready")); + + Browser.Exists(By.Id("code")).SendKeys("XYZ-123"); + Browser.Exists(By.Id("submit")).Click(); + Browser.Equal("Code must start with 'ABC-'.", () => FieldMessage("Form.Code")); + + // A value satisfying the rule clears the error. + ReplaceText(By.Id("code"), "ABC-123"); + Browser.Exists(By.Id("submit")).Click(); + Browser.Equal("", () => FieldMessage("Form.Code")); + } + + [Fact] + public void LocalizedValidation_RoundTripsAcrossCultureSwitches() + { + // French: the carrier payload carries fr-localized display name + message. + NavigateToClientValidationPage("localized-validation?culture=fr"); + Browser.Exists(By.Id("submit")).Click(); + var frenchMessage = Browser.Exists(By.CssSelector("[data-valmsg-for='Form.Email']")).Text; + Assert.Equal("Le champ Adresse e-mail est requis (fr)", frenchMessage); + + // German, on the same shared singleton cache: the per-request localization must not + // be poisoned by the earlier French request. + NavigateToClientValidationPage("localized-validation?culture=de"); + Browser.Exists(By.Id("submit")).Click(); + var germanMessage = Browser.Exists(By.CssSelector("[data-valmsg-for='Form.Email']")).Text; + Assert.Equal("Das Feld E-Mail-Adresse ist erforderlich (de)", germanMessage); + + Assert.NotEqual(frenchMessage, germanMessage); + } + + [Fact] + public void EnableClientValidationFalse_EmitsNoCarrier() + { + // The page is reachable but emits no carrier: the JS engine never activates. + NavigateToClientValidationPage( + "basic-validation?disable-client-validation=true", + expectTrackedForm: false); + + Browser.DoesNotExist(By.CssSelector("blazor-client-validation-data")); + Browser.DoesNotExist(By.CssSelector("form[novalidate]")); + } + + [Fact] + public void InteractiveRenderMode_EmitsNoCarrier() + { + // Inputs rendered in an interactive render mode do not register for client validation + // (no ClientValidationProvider outside Endpoints), so no carrier is emitted. + NavigateToClientValidationPage("interactive-validation", expectTrackedForm: false); + + Browser.DoesNotExist(By.CssSelector("blazor-client-validation-data")); + } + + private string FieldMessage(string fieldName) + => Browser.Exists(By.CssSelector($"[data-valmsg-for='{fieldName}']")).Text; + + private void ReplaceText(By selector, string text) + { + var element = Browser.Exists(selector); + element.Clear(); + element.SendKeys(text); + } + + // Sets a window-scoped flag. Enhanced navigation preserves the JS context, so the flag + // survives; a full page reload would clear it. Used to assert a transition was an enhanced + // navigation (i.e. went through the DOM-merge path this fix targets), not a full reload. + private void MarkEnhancedNavProbe() + => ((IJavaScriptExecutor)Browser).ExecuteScript("window.__enhancedNavProbe = true;"); + + private void AssertWasEnhancedNavigation() + => Browser.True(() => (bool?)((IJavaScriptExecutor)Browser).ExecuteScript( + "return window.__enhancedNavProbe === true;") == true); +} diff --git a/src/Components/test/E2ETest/ServerRenderingTests/ClientValidation/ClientValidationTestBase.cs b/src/Components/test/E2ETest/ServerRenderingTests/ClientValidation/ClientValidationTestBase.cs index 053297da301a..fe3dc467ad87 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/ClientValidation/ClientValidationTestBase.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/ClientValidation/ClientValidationTestBase.cs @@ -15,13 +15,16 @@ namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests.ClientVa // readiness waits + the submit interception that prevents the test forms from // actually POSTing to the server. // +// The test pages render real EditForm components in static SSR. The .NET pipeline +// emits a single carrier element per form whose +// JSON payload the JS engine ingests (registering inputs, setting novalidate). +// // Why the submit interception: -// The test pages render plain SSR forms with raw data-val-* markup and no -// anti-forgery token. When the JS client validation library accepts a submit -// (form is valid), the browser would POST to the SSR endpoint, hit a 400, and -// navigate to a Chromium error page. Subsequent assertions on the original page -// would then fail or, worse, succeed flakily by reading whichever page Selenium -// happened to land on. We install a document-level bubble-phase preventDefault: +// When the JS client validation library accepts a submit (the form is valid), the +// browser would POST to the SSR endpoint and navigate/replace the page. Subsequent +// assertions on the original page would then fail or, worse, succeed flakily by +// reading whichever page Selenium happened to land on. We install a document-level +// bubble-phase preventDefault: // - Invalid submits: the validation library handler runs in capture phase on // document, calls preventDefault + stopPropagation, and the bubble phase is // never reached. No interaction with our handler. @@ -41,12 +44,12 @@ protected ClientValidationTestBase( // Navigates to a /forms/client-validation/ URL, waits for Blazor to // start, and (by default) installs the submit interceptor. Set - // expectTrackedForm to false for pages that intentionally have no - // [data-val=true] elements (e.g. the 'no-validation' page) so the helper - // does not block waiting for form[novalidate] that will never appear. Set - // interceptSubmit to false on pages whose purpose is to verify native form - // submission (e.g. 'no-validation') so the interceptor does not mask the - // behaviour the test is asserting on. + // expectTrackedForm to false for pages that intentionally emit no + // carrier (e.g. a form with client validation + // disabled, or an interactive render mode) so the helper does not block + // waiting for form[novalidate] that will never appear. Set interceptSubmit to + // false on pages whose purpose is to verify native form submission so the + // interceptor does not mask the behaviour the test is asserting on. protected void NavigateToClientValidationPage(string page, bool expectTrackedForm = true, bool interceptSubmit = true) { Navigate($"subdir/forms/client-validation/{page}"); diff --git a/src/Components/test/E2ETest/ServerRenderingTests/FormHandlingTests/FormWithParentBindingContextTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/FormHandlingTests/FormWithParentBindingContextTest.cs index 3b8508b87e5a..b443c672f541 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/FormHandlingTests/FormWithParentBindingContextTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/FormHandlingTests/FormWithParentBindingContextTest.cs @@ -9,7 +9,6 @@ using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; using Microsoft.AspNetCore.E2ETesting; -using Microsoft.AspNetCore.InternalTesting; using OpenQA.Selenium; using TestServer; using Xunit.Abstractions; @@ -125,7 +124,6 @@ public void InputHiddenCanStoreData(bool suppressEnhancedNavigation) [Theory] [InlineData(true)] [InlineData(false)] - [QuarantinedTest("https://github.com/dotnet/aspnetcore/issues/66693")] public void DataAnnotationsWorkForForms(bool suppressEnhancedNavigation) { var dispatchToForm = new DispatchToForm(this) diff --git a/src/Components/test/E2ETest/Tests/FormsInputDateTest.cs b/src/Components/test/E2ETest/Tests/FormsInputDateTest.cs index 1308d87b9ed9..66ecb13f5839 100644 --- a/src/Components/test/E2ETest/Tests/FormsInputDateTest.cs +++ b/src/Components/test/E2ETest/Tests/FormsInputDateTest.cs @@ -236,8 +236,15 @@ public void InputDateInteractsWithEditContext_DateTimeLocalInput_Step() private Func CreateValidationMessagesAccessor(IWebElement appElement) { + // TODO: In SSR form, ValidationMessage renders an empty placeholder
      + // for every field that has a ValidationMessage but no current error. + // This is done so that JS client-side validation can locate the slot for validation errors. + // So we filter out empty-text matches and assert only the fields that + // actually have a server validation error. If the empty-placeholder approach changes in + // the future to avoid the "validation-message" class, revisit this filter. return () => appElement.FindElements(By.ClassName("validation-message")) .Select(x => x.Text) + .Where(text => !string.IsNullOrEmpty(text)) .OrderBy(x => x) .ToArray(); } diff --git a/src/Components/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs index 304ea1721abc..a8c73cbf5cdd 100644 --- a/src/Components/test/E2ETest/Tests/FormsTest.cs +++ b/src/Components/test/E2ETest/Tests/FormsTest.cs @@ -1075,8 +1075,15 @@ public async Task CannotSubmitEditFormSynchronouslyAfterItWasRemoved() private Func CreateValidationMessagesAccessor(IWebElement appElement, string messageSelector = ".validation-message") { + // TODO: In SSR form, ValidationMessage renders an empty placeholder
      + // for every field that has a ValidationMessage but no current error. + // This is done so that JS client-side validation can locate the slot for validation errors. + // So we filter out empty-text matches and assert only the fields that + // actually have a server validation error. If the empty-placeholder approach changes in + // the future to avoid the "validation-message" class, revisit this filter. return () => appElement.FindElements(By.CssSelector(messageSelector)) .Select(x => x.Text) + .Where(text => !string.IsNullOrEmpty(text)) .OrderBy(x => x) .ToArray(); } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/AllValidators.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/AllValidators.razor deleted file mode 100644 index 1907dc7a17ec..000000000000 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/AllValidators.razor +++ /dev/null @@ -1,62 +0,0 @@ -@page "/forms/client-validation/all-validators" - -

      All built-in validators

      - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/BasicValidation.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/BasicValidation.razor index 3eeb7476b282..0843a9e25150 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/BasicValidation.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/BasicValidation.razor @@ -1,91 +1,79 @@ -@page "/forms/client-validation/basic-validation" +@page "/forms/client-validation/basic-validation" +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms

      Basic client-side validation

      -
      -
      -
        -
        - -
        - - - -
        - -
        - - - -
        + + +
        - - - + + +
        - - - + + +
        - - + + +
        - - - + + +
        - - +
        + +@code { + [SupplyParameterFromForm] private BasicValidationModel? Form { get; set; } + + [SupplyParameterFromQuery(Name = "disable-client-validation")] private bool DisableClientValidation { get; set; } + + protected override void OnInitialized() => Form ??= new BasicValidationModel(); + + public class BasicValidationModel + { + [Required(ErrorMessage = "Name is required.")] + public string? Name { get; set; } + + [Required(ErrorMessage = "Email is required.")] + [EmailAddress(ErrorMessage = "Email is not valid.")] + public string? Email { get; set; } + + [Required(ErrorMessage = "Password is required.")] + [StringLength(50, MinimumLength = 8, ErrorMessage = "Password must be between 8 and 50 characters.")] + public string? Password { get; set; } + + [Compare(nameof(Password), ErrorMessage = "Passwords must match.")] + public string? ConfirmPassword { get; set; } + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/CustomValidator.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/CustomValidator.razor index 82a8270fbbb2..2604536270a0 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/CustomValidator.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/CustomValidator.razor @@ -1,39 +1,37 @@ -@page "/forms/client-validation/custom-validator" +@page "/forms/client-validation/custom-validator" +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms

        Custom validator

        -
        + +
        - - - + + +
        - - +
        + +@code { + [SupplyParameterFromForm] private CustomValidatorModel? Form { get; set; } + + protected override void OnInitialized() => Form ??= new CustomValidatorModel(); + + public class CustomValidatorModel + { + [StartsWith("ABC-", ErrorMessage = "Code must start with 'ABC-'.")] + public string? Code { get; set; } + } + + // A custom ValidationAttribute that also contributes a client-side rule by implementing + // IClientValidationAdapter. The framework emits its rule into the carrier payload; the + // matching JS validator is registered above via Blazor.formValidation.addValidator. + private sealed class StartsWithAttribute : ValidationAttribute, IClientValidationAdapter + { + private readonly string _prefix; + + public StartsWithAttribute(string prefix) => _prefix = prefix; + + public override bool IsValid(object? value) + => value is not string text || text.Length == 0 || text.StartsWith(_prefix, StringComparison.Ordinal); + + public IEnumerable GetClientValidationRules() + => new[] + { + new ClientValidationRule( + "startswith", + new Dictionary { ["prefix"] = _prefix }), + }; + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/DynamicContent.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/DynamicContent.razor deleted file mode 100644 index 3ac930d22dda..000000000000 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/DynamicContent.razor +++ /dev/null @@ -1,40 +0,0 @@ -@page "/forms/client-validation/dynamic-content" - -

        Dynamic content + scanRules

        - - - -
        - - - -
        - - -
        - - - - - diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/EditFormValidation.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/EditFormValidation.razor deleted file mode 100644 index 026b46c823ca..000000000000 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/EditFormValidation.razor +++ /dev/null @@ -1,79 +0,0 @@ -@page "/forms/client-validation/editform-validation" -@using System.ComponentModel.DataAnnotations -@using Microsoft.AspNetCore.Components.Forms - -

        EditForm client-side validation

        - -

        - Renders an EditForm with DataAnnotationsValidator + Blazor input - components so the server-side DefaultClientValidationService emits - data-val-* attributes from the model's DataAnnotations. No - IValidationLocalizer involvement — this page pins the non-localized baseline - output of the .NET rendering pipeline. Localized output is covered separately by the - LocalizedValidation page. -

        - - - - -
        - - -
        - -
        - - -
        - -
        - - -
        - -
        - - -
        - -
        - - -
        - -
        - - -
        - - -
        - -@code { - [SupplyParameterFromForm] private EditFormBaselineModel? Form { get; set; } - - protected override void OnInitialized() => Form ??= new EditFormBaselineModel(); - - public class EditFormBaselineModel - { - [Required] - [Display(Name = "Full Name")] - public string? Name { get; set; } - - [StringLength(500, MinimumLength = 10)] - public string? Bio { get; set; } - - [RegularExpression(@"\d{5}")] - public string? ZipCode { get; set; } - - [Url] - public string? Website { get; set; } - - [Required] - public string? Password { get; set; } - - [Compare(nameof(Password))] - [Display(Name = "Confirm Password")] - public string? ConfirmPassword { get; set; } - } -} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/EnhancedNavNoForm.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/EnhancedNavNoForm.razor new file mode 100644 index 000000000000..bc5f95c79c95 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/EnhancedNavNoForm.razor @@ -0,0 +1,7 @@ +@page "/forms/client-validation/enhanced-nav-noform" + +

        Enhanced navigation no-form page

        + +

        This page has no form and no client-validation carrier.

        + +Go to form A diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/EnhancedNavValidationA.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/EnhancedNavValidationA.razor new file mode 100644 index 000000000000..9ddd3ec04af2 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/EnhancedNavValidationA.razor @@ -0,0 +1,35 @@ +@page "/forms/client-validation/enhanced-nav-a" +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms + +

        Enhanced navigation form A

        + + + + + +
        + + + +
        + +
        + +Go to form B +Go to no-form page + +@code { + [SupplyParameterFromForm] private FormAModel? Form { get; set; } + + protected override void OnInitialized() => Form ??= new FormAModel(); + + public class FormAModel + { + [Required(ErrorMessage = "Alpha is required.")] + public string? Alpha { get; set; } + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/EnhancedNavValidationB.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/EnhancedNavValidationB.razor new file mode 100644 index 000000000000..a2894577b7c5 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/EnhancedNavValidationB.razor @@ -0,0 +1,34 @@ +@page "/forms/client-validation/enhanced-nav-b" +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms + +

        Enhanced navigation form B

        + + + + + +
        + + + +
        + +
        + +Go to form A + +@code { + [SupplyParameterFromForm] private FormBModel? Form { get; set; } + + protected override void OnInitialized() => Form ??= new FormBModel(); + + public class FormBModel + { + [Required(ErrorMessage = "Beta is required.")] + public string? Beta { get; set; } + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/Formnovalidate.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/Formnovalidate.razor deleted file mode 100644 index 221995f17e44..000000000000 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/Formnovalidate.razor +++ /dev/null @@ -1,58 +0,0 @@ -@page "/forms/client-validation/formnovalidate" - -

        formnovalidate + skipped fields

        - - - -
        - - - - - - - - - -
        - - -
        - - - -
        - -
        -
        - - diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/InteractiveValidation.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/InteractiveValidation.razor new file mode 100644 index 000000000000..da9b417228b1 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/InteractiveValidation.razor @@ -0,0 +1,37 @@ +@page "/forms/client-validation/interactive-validation" +@rendermode RenderMode.InteractiveServer +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms + +

        Interactive client-side validation

        + + + + + +
        + + + +
        + +
        + +@code { + private InteractiveModel Form { get; set; } = new(); + + private void HandleValidSubmit() + { + // No-op: the page exists only to assert that interactive render modes emit no + // carrier. + } + + public class InteractiveModel + { + [Required(ErrorMessage = "Name is required.")] + public string? Name { get; set; } + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/LocalizedValidation.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/LocalizedValidation.razor index b5f67dfb2f8d..9c7e03ad333f 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/LocalizedValidation.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/LocalizedValidation.razor @@ -5,24 +5,30 @@

        Localized client-side validation

        - Renders an EditForm with model classes decorated with [Required], [Range], - and [Display]. The server's IValidationLocalizer resolves localized strings - based on CurrentUICulture, which is set per-request via the ?culture= - query string. The Selenium test asserts the rendered data-val-* attributes change with - culture, confirming server-client localization parity. + The server's IValidationLocalizer resolves the display name and error message + based on CurrentUICulture (set per request via ?culture=). The + localized strings are baked into the <blazor-client-validation-data> payload, + so the JS engine displays culture-appropriate messages without a round trip.

        + +
        +
        - + +
        diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/MultipleForms.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/MultipleForms.razor index ee993bea77d6..a1244eae4eaf 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/MultipleForms.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/MultipleForms.razor @@ -1,28 +1,53 @@ @page "/forms/client-validation/multiple-forms" +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms

        Multiple independent forms

        -
        -
          - - + + +
          + + + +
          - - -
          -
            - - + + + + +
            + + + +
            - +
            + +@code { + [SupplyParameterFromForm(FormName = "form-a")] private FormAModel? FormA { get; set; } + [SupplyParameterFromForm(FormName = "form-b")] private FormBModel? FormB { get; set; } + + protected override void OnInitialized() + { + FormA ??= new FormAModel(); + FormB ??= new FormBModel(); + } + + public class FormAModel + { + [Required(ErrorMessage = "Name A is required.")] + public string? Name { get; set; } + } + + public class FormBModel + { + [Required(ErrorMessage = "Name B is required.")] + public string? Name { get; set; } + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/NoValidation.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/NoValidation.razor deleted file mode 100644 index b7681c10f7ff..000000000000 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/NoValidation.razor +++ /dev/null @@ -1,14 +0,0 @@ -@page "/forms/client-validation/no-validation" - -@* - Plain form with NO data-val="true" elements. Used to verify that the JS - library does NOT intercept submission of untracked forms (no novalidate - attribute set, form posts normally). -*@ - -

            Form without validation attributes

            - -
            - - -
            diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/RadioGroup.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/RadioGroup.razor deleted file mode 100644 index 58423e3418bc..000000000000 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/RadioGroup.razor +++ /dev/null @@ -1,40 +0,0 @@ -@page "/forms/client-validation/radio-group" - -

            Radio group validation

            - - - -
            -
            - Color (required) - - - -
            -
            - - - - -
            diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/ServerRenderedMessages.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/ServerRenderedMessages.razor deleted file mode 100644 index 602f1fba4310..000000000000 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/ServerRenderedMessages.razor +++ /dev/null @@ -1,36 +0,0 @@ -@page "/forms/client-validation/server-rendered-messages" - -@* - Simulates Blazor SSR rendering after a POST with server-side validation errors. - The first
            for a field has data-valmsg-for (JS manages it); the second is a plain - sibling (extra server-rendered error). JS should remove the sibling when client validation - runs and the field becomes valid or shows a different client-side error. -*@ - -

            Server-rendered validation messages

            - - - -
            -
            - -
            Name is required.
            -
            Name must be at least 2 characters.
            -
            - -
            - -
            Email is required.
            -
            - - -
            diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/StreamingValidation.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/StreamingValidation.razor new file mode 100644 index 000000000000..a3abb01e0bc0 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/StreamingValidation.razor @@ -0,0 +1,49 @@ +@page "/forms/client-validation/streaming-validation" +@attribute [StreamRendering] +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms + +

            Streaming client-side validation

            + + + +@if (_ready) +{ + + +
            + + + +
            + +
            +} +else +{ +

            Streaming...

            +} + +@code { + private bool _ready; + + [SupplyParameterFromForm] private StreamingModel? Form { get; set; } + + protected override async Task OnInitializedAsync() + { + Form ??= new StreamingModel(); + // Defer rendering the form so it is delivered in a streaming update (going through the + // DOM-merge + 'enhancedload' path) rather than in the initial response. + await Task.Delay(100); + _ready = true; + } + + public class StreamingModel + { + [Required(ErrorMessage = "Gamma is required.")] + public string? Gamma { get; set; } + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/Timing.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/Timing.razor deleted file mode 100644 index 6330b8c6d301..000000000000 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ClientValidation/Timing.razor +++ /dev/null @@ -1,40 +0,0 @@ -@page "/forms/client-validation/timing" - -

            Validation triggers

            - - - -
            - @* Default: change + gated input *@ - - - - @* data-valevent="submit": only validated on form submit *@ - - - - @* data-valevent="change": only validated on change/blur, never on input *@ - - - - @* data-valevent="input": always validated on every keystroke *@ - - - - - -
            diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/DefaultFormBoundParameterAnnotations.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/DefaultFormBoundParameterAnnotations.razor index 50fbdc242d89..8f4b5032c004 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/DefaultFormBoundParameterAnnotations.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/DefaultFormBoundParameterAnnotations.razor @@ -6,7 +6,7 @@

            Default form with bound parameter using data annotations for validation and data member to tweak the binding.

            - +