Jelajahi Sumber

Validation fixes for Blazor (#14972)

* Validation fixes for Blazor

* Ensure validation result that are not associated with a member are recorded. Fixes https://github.com/aspnet/AspNetCore/issues/10643
* Add support for showing model-specific errors to ValidationSummary
* Add support for nested validation and a more suitable CompareAttribute. Fixes https://github.com/aspnet/AspNetCore/issues/10526
Pranav K 6 tahun lalu
induk
melakukan
c298c94fe1
26 mengubah file dengan 1239 tambahan dan 40 penghapusan
  1. 1 0
      eng/ProjectReferences.props
  2. 34 0
      src/Components/Blazor/Validation/src/ComparePropertyAttribute.cs
  3. 18 0
      src/Components/Blazor/Validation/src/Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.csproj
  4. 125 0
      src/Components/Blazor/Validation/src/ObjectGraphDataAnnotationsValidator.cs
  5. 30 0
      src/Components/Blazor/Validation/src/ValidateComplexTypeAttribute.cs
  6. 11 0
      src/Components/Blazor/Validation/test/Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.Tests.csproj
  7. 540 0
      src/Components/Blazor/Validation/test/ObjectGraphDataAnnotationsValidatorTest.cs
  8. 33 0
      src/Components/Components.sln
  9. 2 0
      src/Components/ComponentsNoDeps.slnf
  10. 6 0
      src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs
  11. 1 0
      src/Components/Samples/BlazorServerApp/BlazorServerApp.csproj
  12. 0 0
      src/Components/Web.JS/dist/Release/blazor.server.js
  13. 2 0
      src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs
  14. 2 0
      src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netstandard2.0.cs
  15. 27 12
      src/Components/Web/src/Forms/ValidationSummary.cs
  16. 57 19
      src/Components/test/E2ETest/Tests/FormsTest.cs
  17. 90 0
      src/Components/test/E2ETest/Tests/FormsTestWithExperimentalValidator.cs
  18. 1 0
      src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj
  19. 185 0
      src/Components/test/testassets/BasicTestApp/FormsTest/ExperimentalValidationComponent.razor
  20. 10 1
      src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.razor
  21. 7 0
      src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponentUsingExperimentalValidator.cs
  22. 20 0
      src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor
  23. 7 0
      src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponentUsingExperimentalValidator.cs
  24. 3 0
      src/Components/test/testassets/BasicTestApp/Index.razor
  25. 24 7
      src/Shared/E2ETesting/BrowserAssertFailedException.cs
  26. 3 1
      src/Shared/E2ETesting/WaitAssert.cs

+ 1 - 0
eng/ProjectReferences.props

@@ -15,6 +15,7 @@
     <ProjectReferenceProvider Include="GetDocument.Insider" ProjectPath="$(RepoRoot)src\Tools\GetDocumentInsider\src\GetDocumentInsider.csproj"  />
     <ProjectReferenceProvider Include="Microsoft.AspNetCore.SignalR.Specification.Tests" ProjectPath="$(RepoRoot)src\SignalR\server\Specification.Tests\src\Microsoft.AspNetCore.SignalR.Specification.Tests.csproj"  />
     <ProjectReferenceProvider Include="Microsoft.AspNetCore.Blazor.Build" ProjectPath="$(RepoRoot)src\Components\Blazor\Build\src\Microsoft.AspNetCore.Blazor.Build.csproj"  />
+    <ProjectReferenceProvider Include="Microsoft.AspNetCore.Blazor.DataAnnotations.Validation" ProjectPath="$(RepoRoot)src\Components\Blazor\Validation\src\Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.csproj"  />
     <ProjectReferenceProvider Include="Ignitor" ProjectPath="$(RepoRoot)src\Components\Ignitor\src\Ignitor.csproj"  />
     <ProjectReferenceProvider Include="BlazorServerApp" ProjectPath="$(RepoRoot)src\Components\Samples\BlazorServerApp\BlazorServerApp.csproj"  />
     <ProjectReferenceProvider Include="Microsoft.AspNetCore" ProjectPath="$(RepoRoot)src\DefaultBuilder\src\Microsoft.AspNetCore.csproj" RefProjectPath="$(RepoRoot)src\DefaultBuilder\ref\Microsoft.AspNetCore.csproj" />

+ 34 - 0
src/Components/Blazor/Validation/src/ComparePropertyAttribute.cs

@@ -0,0 +1,34 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+namespace System.ComponentModel.DataAnnotations
+{
+    /// <summary>
+    /// A <see cref="ValidationAttribute"/> that compares two properties
+    /// </summary>
+    [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
+    public sealed class ComparePropertyAttribute : CompareAttribute
+    {
+        /// <summary>
+        /// Initializes a new instance of <see cref="BlazorCompareAttribute"/>.
+        /// </summary>
+        /// <param name="otherProperty">The property to compare with the current property.</param>
+        public ComparePropertyAttribute(string otherProperty)
+            : base(otherProperty)
+        {
+        }
+
+        /// <inheritdoc />
+        protected override ValidationResult IsValid(object value, ValidationContext validationContext)
+        {
+            var validationResult = base.IsValid(value, validationContext);
+            if (validationResult == ValidationResult.Success)
+            {
+                return validationResult;
+            }
+
+            return new ValidationResult(validationResult.ErrorMessage, new[] { validationContext.MemberName });
+        }
+    }
+}
+

+ 18 - 0
src/Components/Blazor/Validation/src/Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.csproj

@@ -0,0 +1,18 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>netstandard2.0</TargetFramework>
+    <Description>Provides experimental support for validation using DataAnnotations.</Description>
+    <IsShippingPackage>true</IsShippingPackage>
+    <HasReferenceAssembly>false</HasReferenceAssembly>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Reference Include="Microsoft.AspNetCore.Components.Forms" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <InternalsVisibleTo Include="Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.Tests" />
+  </ItemGroup>
+
+</Project>

+ 125 - 0
src/Components/Blazor/Validation/src/ObjectGraphDataAnnotationsValidator.cs

@@ -0,0 +1,125 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+    public class ObjectGraphDataAnnotationsValidator : ComponentBase
+    {
+        private static readonly object ValidationContextValidatorKey = new object();
+        private static readonly object ValidatedObjectsKey = new object();
+        private ValidationMessageStore _validationMessageStore;
+
+        [CascadingParameter]
+        internal EditContext EditContext { get; set; }
+
+        protected override void OnInitialized()
+        {
+            _validationMessageStore = new ValidationMessageStore(EditContext);
+
+            // Perform object-level validation (starting from the root model) on request
+            EditContext.OnValidationRequested += (sender, eventArgs) =>
+            {
+                _validationMessageStore.Clear();
+                ValidateObject(EditContext.Model, new HashSet<object>());
+                EditContext.NotifyValidationStateChanged();
+            };
+
+            // Perform per-field validation on each field edit
+            EditContext.OnFieldChanged += (sender, eventArgs) =>
+                ValidateField(EditContext, _validationMessageStore, eventArgs.FieldIdentifier);
+        }
+
+        internal void ValidateObject(object value, HashSet<object> visited)
+        {
+            if (value is null)
+            {
+                return;
+            }
+
+            if (!visited.Add(value))
+            {
+                // Already visited this object.
+                return;
+            }
+
+            if (value is IEnumerable<object> enumerable)
+            {
+                var index = 0;
+                foreach (var item in enumerable)
+                {
+                    ValidateObject(item, visited);
+                    index++;
+                }
+
+                return;
+            }
+
+            var validationResults = new List<ValidationResult>();
+            ValidateObject(value, visited, validationResults);
+
+            // Transfer results to the ValidationMessageStore
+            foreach (var validationResult in validationResults)
+            {
+                if (!validationResult.MemberNames.Any())
+                {
+                    _validationMessageStore.Add(new FieldIdentifier(value, string.Empty), validationResult.ErrorMessage);
+                    continue;
+                }
+
+                foreach (var memberName in validationResult.MemberNames)
+                {
+                    var fieldIdentifier = new FieldIdentifier(value, memberName);
+                    _validationMessageStore.Add(fieldIdentifier, validationResult.ErrorMessage);
+                }
+            }
+        }
+
+        private void ValidateObject(object value, HashSet<object> visited, List<ValidationResult> validationResults)
+        {
+            var validationContext = new ValidationContext(value);
+            validationContext.Items.Add(ValidationContextValidatorKey, this);
+            validationContext.Items.Add(ValidatedObjectsKey, visited);
+            Validator.TryValidateObject(value, validationContext, validationResults, validateAllProperties: true);
+        }
+
+        internal static bool TryValidateRecursive(object value, ValidationContext validationContext)
+        {
+            if (validationContext.Items.TryGetValue(ValidationContextValidatorKey, out var result) && result is ObjectGraphDataAnnotationsValidator validator)
+            {
+                var visited = (HashSet<object>)validationContext.Items[ValidatedObjectsKey];
+                validator.ValidateObject(value, visited);
+
+                return true;
+            }
+
+            return false;
+        }
+
+        private static void ValidateField(EditContext editContext, ValidationMessageStore messages, in FieldIdentifier fieldIdentifier)
+        {
+            // DataAnnotations only validates public properties, so that's all we'll look for
+            var propertyInfo = fieldIdentifier.Model.GetType().GetProperty(fieldIdentifier.FieldName);
+            if (propertyInfo != null)
+            {
+                var propertyValue = propertyInfo.GetValue(fieldIdentifier.Model);
+                var validationContext = new ValidationContext(fieldIdentifier.Model)
+                {
+                    MemberName = propertyInfo.Name
+                };
+                var results = new List<ValidationResult>();
+
+                Validator.TryValidateProperty(propertyValue, validationContext, results);
+                messages.Clear(fieldIdentifier);
+                messages.Add(fieldIdentifier, results.Select(result => result.ErrorMessage));
+
+                // We have to notify even if there were no messages before and are still no messages now,
+                // because the "state" that changed might be the completion of some async validation task
+                editContext.NotifyValidationStateChanged();
+            }
+        }
+    }
+}

+ 30 - 0
src/Components/Blazor/Validation/src/ValidateComplexTypeAttribute.cs

@@ -0,0 +1,30 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using Microsoft.AspNetCore.Components.Forms;
+
+namespace System.ComponentModel.DataAnnotations
+{
+    /// <summary>
+    /// A <see cref="ValidationAttribute"/> that indicates that the property is a complex or collection type that further needs to be validated.
+    /// <para>
+    /// By default <see cref="Validator"/> does not recurse in to complex property types during validation.
+    /// When used in conjunction with <see cref="ObjectGraphDataAnnotationsValidator"/>, this property allows the validation system to validate
+    /// complex or collection type properties.
+    /// </para>
+    /// </summary>
+    [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
+    public sealed class ValidateComplexTypeAttribute : ValidationAttribute
+    {
+        /// <inheritdoc />
+        protected override ValidationResult IsValid(object value, ValidationContext validationContext)
+        {
+            if (!ObjectGraphDataAnnotationsValidator.TryValidateRecursive(value, validationContext))
+            {
+                throw new InvalidOperationException($"{nameof(ValidateComplexTypeAttribute)} can only used with {nameof(ObjectGraphDataAnnotationsValidator)}.");
+            }
+
+            return ValidationResult.Success;
+        }
+    }
+}

+ 11 - 0
src/Components/Blazor/Validation/test/Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.Tests.csproj

@@ -0,0 +1,11 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Reference Include="Microsoft.AspNetCore.Blazor.DataAnnotations.Validation" />
+  </ItemGroup>
+
+</Project>

+ 540 - 0
src/Components/Blazor/Validation/test/ObjectGraphDataAnnotationsValidatorTest.cs

@@ -0,0 +1,540 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using Microsoft.AspNetCore.Components.Forms;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Components
+{
+    public class ObjectGraphDataAnnotationsValidatorTest
+    {
+        public class SimpleModel
+        {
+            [Required]
+            public string Name { get; set; }
+
+            [Range(1, 16)]
+            public int Age { get; set; }
+        }
+
+        [Fact]
+        public void ValidateObject_SimpleObject()
+        {
+            var model = new SimpleModel
+            {
+                Age = 23,
+            };
+
+            var editContext = Validate(model);
+            var messages = editContext.GetValidationMessages(() => model.Name);
+            Assert.Single(messages);
+
+            messages = editContext.GetValidationMessages(() => model.Age);
+            Assert.Single(messages);
+
+            Assert.Equal(2, editContext.GetValidationMessages().Count());
+        }
+
+        [Fact]
+        public void ValidateObject_SimpleObject_AllValid()
+        {
+            var model = new SimpleModel { Name = "Test", Age = 5 };
+
+            var editContext = Validate(model);
+            var messages = editContext.GetValidationMessages(() => model.Name);
+            Assert.Empty(messages);
+
+            messages = editContext.GetValidationMessages(() => model.Age);
+            Assert.Empty(messages);
+
+            Assert.Empty(editContext.GetValidationMessages());
+        }
+
+        public class ModelWithComplexProperty
+        {
+            [Required]
+            public string Property1 { get; set; }
+
+            [ValidateComplexType]
+            public SimpleModel SimpleModel { get; set; }
+        }
+
+        [Fact]
+        public void ValidateObject_NullComplexProperty()
+        {
+            var model = new ModelWithComplexProperty();
+
+            var editContext = Validate(model);
+            var messages = editContext.GetValidationMessages(() => model.Property1);
+            Assert.Single(messages);
+
+            Assert.Single(editContext.GetValidationMessages());
+        }
+
+        [Fact]
+        public void ValidateObject_ModelWithComplexProperties()
+        {
+            var model = new ModelWithComplexProperty { SimpleModel = new SimpleModel() };
+
+            var editContext = Validate(model);
+            var messages = editContext.GetValidationMessages(() => model.Property1);
+            Assert.Single(messages);
+
+            messages = editContext.GetValidationMessages(() => model.SimpleModel);
+            Assert.Empty(messages);
+
+            messages = editContext.GetValidationMessages(() => model.SimpleModel.Age);
+            Assert.Single(messages);
+
+            messages = editContext.GetValidationMessages(() => model.SimpleModel.Name);
+            Assert.Single(messages);
+
+            Assert.Equal(3, editContext.GetValidationMessages().Count());
+        }
+
+        [Fact]
+        public void ValidateObject_ModelWithComplexProperties_SomeValid()
+        {
+            var model = new ModelWithComplexProperty
+            {
+                Property1 = "Value",
+                SimpleModel = new SimpleModel { Name = "Some Value" },
+            };
+
+            var editContext = Validate(model);
+            var messages = editContext.GetValidationMessages(() => model.Property1);
+            Assert.Empty(messages);
+
+            messages = editContext.GetValidationMessages(() => model.SimpleModel);
+            Assert.Empty(messages);
+
+            messages = editContext.GetValidationMessages(() => model.SimpleModel.Age);
+            Assert.Single(messages);
+
+            messages = editContext.GetValidationMessages(() => model.SimpleModel.Name);
+            Assert.Empty(messages);
+
+            Assert.Single(editContext.GetValidationMessages());
+        }
+
+        public class TestValidatableObject : IValidatableObject
+        {
+            [Required]
+            public string Name { get; set; }
+
+            public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
+            {
+                yield return new ValidationResult("Custom validation error");
+            }
+        }
+
+        public class ModelWithValidatableComplexProperty
+        {
+            [Required]
+            public string Property1 { get; set; }
+
+            [ValidateComplexType]
+            public TestValidatableObject Property2 { get; set; } = new TestValidatableObject();
+        }
+
+        [Fact]
+        public void ValidateObject_ValidatableComplexProperty()
+        {
+            var model = new ModelWithValidatableComplexProperty();
+
+            var editContext = Validate(model);
+            var messages = editContext.GetValidationMessages(() => model.Property1);
+            Assert.Single(messages);
+
+            messages = editContext.GetValidationMessages(() => model.Property2);
+            Assert.Empty(messages);
+
+            messages = editContext.GetValidationMessages(() => model.Property2.Name);
+            Assert.Single(messages);
+
+            Assert.Equal(2, editContext.GetValidationMessages().Count());
+        }
+
+        [Fact]
+        public void ValidateObject_ValidatableComplexProperty_ValidatesIValidatableProperty()
+        {
+            var model = new ModelWithValidatableComplexProperty
+            {
+                Property2 = new TestValidatableObject { Name = "test" },
+            };
+
+            var editContext = Validate(model);
+            var messages = editContext.GetValidationMessages(() => model.Property1);
+            Assert.Single(messages);
+
+            messages = editContext.GetValidationMessages(new FieldIdentifier(model.Property2, string.Empty));
+            Assert.Single(messages);
+
+            messages = editContext.GetValidationMessages(() => model.Property2.Name);
+            Assert.Empty(messages);
+
+            Assert.Equal(2, editContext.GetValidationMessages().Count());
+        }
+
+        [Fact]
+        public void ValidateObject_ModelIsIValidatable_PropertyHasError()
+        {
+            var model = new TestValidatableObject();
+
+            var editContext = Validate(model);
+            var messages = editContext.GetValidationMessages(new FieldIdentifier(model, string.Empty));
+            Assert.Empty(messages);
+
+            messages = editContext.GetValidationMessages(() => model.Name);
+            Assert.Single(messages);
+
+            Assert.Single(editContext.GetValidationMessages());
+        }
+
+        [Fact]
+        public void ValidateObject_ModelIsIValidatable_ModelHasError()
+        {
+            var model = new TestValidatableObject { Name = "test" };
+
+            var editContext = Validate(model);
+            var messages = editContext.GetValidationMessages(new FieldIdentifier(model, string.Empty));
+            Assert.Single(messages);
+
+            messages = editContext.GetValidationMessages(() => model.Name);
+            Assert.Empty(messages);
+
+            Assert.Single(editContext.GetValidationMessages());
+        }
+
+        [Fact]
+        public void ValidateObject_CollectionModel()
+        {
+            var model = new List<SimpleModel>
+            {
+                new SimpleModel(),
+                new SimpleModel { Name = "test", },
+            };
+
+            var editContext = Validate(model);
+
+            var item = model[0];
+            var messages = editContext.GetValidationMessages(new FieldIdentifier(model, "0"));
+            Assert.Empty(messages);
+
+            messages = editContext.GetValidationMessages(() => item.Name);
+            Assert.Single(messages);
+
+            messages = editContext.GetValidationMessages(() => item.Age);
+            Assert.Single(messages);
+
+            item = model[1];
+            messages = editContext.GetValidationMessages(new FieldIdentifier(model, "1"));
+            Assert.Empty(messages);
+
+            messages = editContext.GetValidationMessages(() => item.Name);
+            Assert.Empty(messages);
+
+            messages = editContext.GetValidationMessages(() => item.Age);
+            Assert.Single(messages);
+
+            Assert.Equal(3, editContext.GetValidationMessages().Count());
+        }
+
+        [Fact]
+        public void ValidateObject_CollectionValidatableModel()
+        {
+            var model = new List<TestValidatableObject>
+            {
+                new TestValidatableObject(),
+                new TestValidatableObject { Name = "test", },
+            };
+
+            var editContext = Validate(model);
+
+            var item = model[0];
+            var messages = editContext.GetValidationMessages(() => item.Name);
+            Assert.Single(messages);
+
+            item = model[1];
+            Assert.Single(messages);
+
+            messages = editContext.GetValidationMessages(() => item.Name);
+            Assert.Empty(messages);
+
+            Assert.Equal(2, editContext.GetValidationMessages().Count());
+        }
+
+        private class Level1Validation
+        {
+            [ValidateComplexType]
+            public Level2Validation Level2 { get; set; }
+        }
+
+        public class Level2Validation
+        {
+            [ValidateComplexType]
+            public SimpleModel Level3 { get; set; }
+        }
+
+        [Fact]
+        public void ValidateObject_ManyLevels()
+        {
+            var model = new Level1Validation
+            {
+                Level2 = new Level2Validation
+                {
+                    Level3 = new SimpleModel
+                    {
+                        Age = 47,
+                    }
+                }
+            };
+
+            var editContext = Validate(model);
+            var level3 = model.Level2.Level3;
+
+            var messages = editContext.GetValidationMessages(() => level3.Name);
+            Assert.Single(messages);
+
+            messages = editContext.GetValidationMessages(() => level3.Age);
+            Assert.Single(messages);
+
+            Assert.Equal(2, editContext.GetValidationMessages().Count());
+        }
+
+        private class Person
+        {
+            [Required]
+            public string Name { get; set; }
+
+            [ValidateComplexType]
+            public Person Related { get; set; }
+        }
+
+        [Fact]
+        public void ValidateObject_RecursiveRelation()
+        {
+            var model = new Person { Related = new Person() };
+            model.Related.Related = model;
+
+            var editContext = Validate(model);
+
+            var messages = editContext.GetValidationMessages(() => model.Name);
+            Assert.Single(messages);
+
+            messages = editContext.GetValidationMessages(() => model.Related.Name);
+            Assert.Single(messages);
+
+            Assert.Equal(2, editContext.GetValidationMessages().Count());
+        }
+
+        [Fact]
+        public void ValidateObject_RecursiveRelation_OverManySteps()
+        {
+            var person1 = new Person();
+            var person2 = new Person { Name = "Valid name" };
+            var person3 = new Person();
+            var person4 = new Person();
+
+            person1.Related = person2;
+            person2.Related = person3;
+            person3.Related = person4;
+            person4.Related = person1;
+
+            var editContext = Validate(person1);
+
+            var messages = editContext.GetValidationMessages(() => person1.Name);
+            Assert.Single(messages);
+
+            messages = editContext.GetValidationMessages(() => person2.Name);
+            Assert.Empty(messages);
+
+            messages = editContext.GetValidationMessages(() => person3.Name);
+            Assert.Single(messages);
+
+            messages = editContext.GetValidationMessages(() => person4.Name);
+            Assert.Single(messages);
+
+            Assert.Equal(3, editContext.GetValidationMessages().Count());
+        }
+
+        private class Node
+        {
+            [Required]
+            public string Id { get; set; }
+
+            [ValidateComplexType]
+            public List<Node> Related { get; set; } = new List<Node>();
+        }
+
+        [Fact]
+        public void ValidateObject_RecursiveRelation_ViaCollection()
+        {
+            var node1 = new Node();
+            var node2 = new Node { Id = "Valid Id" };
+            var node3 = new Node();
+            node1.Related.Add(node2);
+            node2.Related.Add(node3);
+            node3.Related.Add(node1);
+
+            var editContext = Validate(node1);
+
+            var messages = editContext.GetValidationMessages(() => node1.Id);
+            Assert.Single(messages);
+
+            messages = editContext.GetValidationMessages(() => node2.Id);
+            Assert.Empty(messages);
+
+            messages = editContext.GetValidationMessages(() => node3.Id);
+            Assert.Single(messages);
+
+            Assert.Equal(2, editContext.GetValidationMessages().Count());
+        }
+
+        [Fact]
+        public void ValidateObject_RecursiveRelation_InCollection()
+        {
+            var person1 = new Person();
+            var person2 = new Person { Name = "Valid name" };
+            var person3 = new Person();
+            var person4 = new Person();
+
+            person1.Related = person2;
+            person2.Related = person3;
+            person3.Related = person4;
+            person4.Related = person1;
+
+            var editContext = Validate(person1);
+
+            var messages = editContext.GetValidationMessages(() => person1.Name);
+            Assert.Single(messages);
+
+            messages = editContext.GetValidationMessages(() => person2.Name);
+            Assert.Empty(messages);
+
+            messages = editContext.GetValidationMessages(() => person3.Name);
+            Assert.Single(messages);
+
+            messages = editContext.GetValidationMessages(() => person4.Name);
+            Assert.Single(messages);
+
+            Assert.Equal(3, editContext.GetValidationMessages().Count());
+        }
+
+        [Fact]
+        public void ValidateField_PropertyValid()
+        {
+            var model = new SimpleModel { Age = 1 };
+            var fieldIdentifier = FieldIdentifier.Create(() => model.Age);
+
+            var editContext = ValidateField(model, fieldIdentifier);
+            var messages = editContext.GetValidationMessages(fieldIdentifier);
+            Assert.Empty(messages);
+
+            Assert.Empty(editContext.GetValidationMessages());
+        }
+
+        [Fact]
+        public void ValidateField_PropertyInvalid()
+        {
+            var model = new SimpleModel { Age = 42 };
+            var fieldIdentifier = FieldIdentifier.Create(() => model.Age);
+
+            var editContext = ValidateField(model, fieldIdentifier);
+            var messages = editContext.GetValidationMessages(fieldIdentifier);
+            Assert.Single(messages);
+
+            Assert.Single(editContext.GetValidationMessages());
+        }
+
+        [Fact]
+        public void ValidateField_AfterSubmitValidation()
+        {
+            var model = new SimpleModel { Age = 42 };
+            var fieldIdentifier = FieldIdentifier.Create(() => model.Age);
+
+            var editContext = Validate(model);
+            var messages = editContext.GetValidationMessages(fieldIdentifier);
+            Assert.Single(messages);
+
+            Assert.Equal(2, editContext.GetValidationMessages().Count());
+
+            model.Age = 4;
+
+            editContext.NotifyFieldChanged(fieldIdentifier);
+            messages = editContext.GetValidationMessages(fieldIdentifier);
+            Assert.Empty(messages);
+
+            Assert.Single(editContext.GetValidationMessages());
+        }
+
+        [Fact]
+        public void ValidateField_ModelWithComplexProperty()
+        {
+            var model = new ModelWithComplexProperty
+            {
+                SimpleModel = new SimpleModel { Age = 1 },
+            };
+            var fieldIdentifier = FieldIdentifier.Create(() => model.SimpleModel.Name);
+
+            var editContext = ValidateField(model, fieldIdentifier);
+            var messages = editContext.GetValidationMessages(fieldIdentifier);
+            Assert.Single(messages);
+
+            Assert.Single(editContext.GetValidationMessages());
+        }
+
+        [Fact]
+        public void ValidateField_ModelWithComplexProperty_AfterSubmitValidation()
+        {
+            var model = new ModelWithComplexProperty
+            {
+                Property1 = "test",
+                SimpleModel = new SimpleModel { Age = 29, Name = "Test" },
+            };
+            var fieldIdentifier = FieldIdentifier.Create(() => model.SimpleModel.Age);
+
+            var editContext = Validate(model);
+            var messages = editContext.GetValidationMessages(fieldIdentifier);
+            Assert.Single(messages);
+
+            model.SimpleModel.Age = 9;
+            editContext.NotifyFieldChanged(fieldIdentifier);
+
+            messages = editContext.GetValidationMessages(fieldIdentifier);
+            Assert.Empty(messages);
+            Assert.Empty(editContext.GetValidationMessages());
+        }
+
+        private static EditContext Validate(object model)
+        {
+            var editContext = new EditContext(model);
+            var validator = new TestObjectGraphDataAnnotationsValidator { EditContext = editContext, };
+            validator.OnInitialized();
+
+            editContext.Validate();
+
+            return editContext;
+        }
+
+        private static EditContext ValidateField(object model, in FieldIdentifier field)
+        {
+            var editContext = new EditContext(model);
+            var validator = new TestObjectGraphDataAnnotationsValidator { EditContext = editContext, };
+            validator.OnInitialized();
+
+            editContext.NotifyFieldChanged(field);
+
+            return editContext;
+        }
+
+        private class TestObjectGraphDataAnnotationsValidator : ObjectGraphDataAnnotationsValidator
+        {
+            public new void OnInitialized() => base.OnInitialized();
+        }
+    }
+}

+ 33 - 0
src/Components/Components.sln

@@ -240,6 +240,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ignitor", "Ignitor\src\Igni
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ignitor.Test", "Ignitor\test\Ignitor.Test.csproj", "{F31E8118-014E-4CCE-8A48-5282F7B9BB3E}"
 EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Validation", "Validation", "{FD9BD646-9D50-42ED-A3E1-90558BA0C6B2}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Blazor.DataAnnotations.Validation", "Blazor\Validation\src\Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.csproj", "{B70F90C7-2696-4050-B24E-BF0308F4E059}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.Tests", "Blazor\Validation\test\Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.Tests.csproj", "{A5617A9D-C71E-44DE-936C-27611EB40A02}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -1486,6 +1492,30 @@ Global
 		{F31E8118-014E-4CCE-8A48-5282F7B9BB3E}.Release|x64.Build.0 = Release|Any CPU
 		{F31E8118-014E-4CCE-8A48-5282F7B9BB3E}.Release|x86.ActiveCfg = Release|Any CPU
 		{F31E8118-014E-4CCE-8A48-5282F7B9BB3E}.Release|x86.Build.0 = Release|Any CPU
+		{B70F90C7-2696-4050-B24E-BF0308F4E059}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{B70F90C7-2696-4050-B24E-BF0308F4E059}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{B70F90C7-2696-4050-B24E-BF0308F4E059}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{B70F90C7-2696-4050-B24E-BF0308F4E059}.Debug|x64.Build.0 = Debug|Any CPU
+		{B70F90C7-2696-4050-B24E-BF0308F4E059}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{B70F90C7-2696-4050-B24E-BF0308F4E059}.Debug|x86.Build.0 = Debug|Any CPU
+		{B70F90C7-2696-4050-B24E-BF0308F4E059}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{B70F90C7-2696-4050-B24E-BF0308F4E059}.Release|Any CPU.Build.0 = Release|Any CPU
+		{B70F90C7-2696-4050-B24E-BF0308F4E059}.Release|x64.ActiveCfg = Release|Any CPU
+		{B70F90C7-2696-4050-B24E-BF0308F4E059}.Release|x64.Build.0 = Release|Any CPU
+		{B70F90C7-2696-4050-B24E-BF0308F4E059}.Release|x86.ActiveCfg = Release|Any CPU
+		{B70F90C7-2696-4050-B24E-BF0308F4E059}.Release|x86.Build.0 = Release|Any CPU
+		{A5617A9D-C71E-44DE-936C-27611EB40A02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{A5617A9D-C71E-44DE-936C-27611EB40A02}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{A5617A9D-C71E-44DE-936C-27611EB40A02}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{A5617A9D-C71E-44DE-936C-27611EB40A02}.Debug|x64.Build.0 = Debug|Any CPU
+		{A5617A9D-C71E-44DE-936C-27611EB40A02}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{A5617A9D-C71E-44DE-936C-27611EB40A02}.Debug|x86.Build.0 = Debug|Any CPU
+		{A5617A9D-C71E-44DE-936C-27611EB40A02}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{A5617A9D-C71E-44DE-936C-27611EB40A02}.Release|Any CPU.Build.0 = Release|Any CPU
+		{A5617A9D-C71E-44DE-936C-27611EB40A02}.Release|x64.ActiveCfg = Release|Any CPU
+		{A5617A9D-C71E-44DE-936C-27611EB40A02}.Release|x64.Build.0 = Release|Any CPU
+		{A5617A9D-C71E-44DE-936C-27611EB40A02}.Release|x86.ActiveCfg = Release|Any CPU
+		{A5617A9D-C71E-44DE-936C-27611EB40A02}.Release|x86.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
@@ -1596,6 +1626,9 @@ Global
 		{BBF37AF9-8290-4B70-8BA8-0F6017B3B620} = {46E4300C-5726-4108-B9A2-18BB94EB26ED}
 		{CD0EF85C-4187-4515-A355-E5A0D4485F40} = {BDE2397D-C53A-4783-8B3A-1F54F48A6926}
 		{F31E8118-014E-4CCE-8A48-5282F7B9BB3E} = {BDE2397D-C53A-4783-8B3A-1F54F48A6926}
+		{FD9BD646-9D50-42ED-A3E1-90558BA0C6B2} = {7260DED9-22A9-4E9D-92F4-5E8A4404DEAF}
+		{B70F90C7-2696-4050-B24E-BF0308F4E059} = {7260DED9-22A9-4E9D-92F4-5E8A4404DEAF}
+		{A5617A9D-C71E-44DE-936C-27611EB40A02} = {7260DED9-22A9-4E9D-92F4-5E8A4404DEAF}
 	EndGlobalSection
 	GlobalSection(ExtensibilityGlobals) = postSolution
 		SolutionGuid = {CC3C47E1-AD1A-4619-9CD3-E08A0148E5CE}

+ 2 - 0
src/Components/ComponentsNoDeps.slnf

@@ -15,6 +15,8 @@
       "Blazor\\Http\\test\\Microsoft.AspNetCore.Blazor.HttpClient.Tests.csproj",
       "Blazor\\Server\\src\\Microsoft.AspNetCore.Blazor.Server.csproj",
       "Blazor\\Templates\\src\\Microsoft.AspNetCore.Blazor.Templates.csproj",
+      "Blazor\\Validation\\src\\Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.csproj",
+      "Blazor\\Validation\\test\\Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.Tests.csproj",
       "Blazor\\testassets\\HostedInAspNet.Client\\HostedInAspNet.Client.csproj",
       "Blazor\\testassets\\HostedInAspNet.Server\\HostedInAspNet.Server.csproj",
       "Blazor\\testassets\\Microsoft.AspNetCore.Blazor.E2EPerformance\\Microsoft.AspNetCore.Blazor.E2EPerformance.csproj",

+ 6 - 0
src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs

@@ -52,6 +52,12 @@ namespace Microsoft.AspNetCore.Components.Forms
             messages.Clear();
             foreach (var validationResult in validationResults)
             {
+                if (!validationResult.MemberNames.Any())
+                {
+                    messages.Add(new FieldIdentifier(editContext.Model, fieldName: string.Empty), validationResult.ErrorMessage);
+                    continue;
+                }
+
                 foreach (var memberName in validationResult.MemberNames)
                 {
                     messages.Add(editContext.Field(memberName), validationResult.ErrorMessage);

+ 1 - 0
src/Components/Samples/BlazorServerApp/BlazorServerApp.csproj

@@ -12,6 +12,7 @@
     <Reference Include="Microsoft.AspNetCore.HttpsPolicy" />
     <Reference Include="Microsoft.AspNetCore.Mvc" />
     <Reference Include="Microsoft.Extensions.Hosting" />
+    <Reference Include="Microsoft.AspNetCore.Blazor.DataAnnotations.Validation" />
   </ItemGroup>
 
 </Project>

File diff ditekan karena terlalu besar
+ 0 - 0
src/Components/Web.JS/dist/Release/blazor.server.js


+ 2 - 0
src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs

@@ -125,6 +125,8 @@ namespace Microsoft.AspNetCore.Components.Forms
         public ValidationSummary() { }
         [Microsoft.AspNetCore.Components.ParameterAttribute(CaptureUnmatchedValues=true)]
         public System.Collections.Generic.IReadOnlyDictionary<string, object> AdditionalAttributes { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
+        [Microsoft.AspNetCore.Components.ParameterAttribute]
+        public object Model { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
         protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { }
         protected virtual void Dispose(bool disposing) { }
         protected override void OnParametersSet() { }

+ 2 - 0
src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netstandard2.0.cs

@@ -125,6 +125,8 @@ namespace Microsoft.AspNetCore.Components.Forms
         public ValidationSummary() { }
         [Microsoft.AspNetCore.Components.ParameterAttribute(CaptureUnmatchedValues=true)]
         public System.Collections.Generic.IReadOnlyDictionary<string, object> AdditionalAttributes { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
+        [Microsoft.AspNetCore.Components.ParameterAttribute]
+        public object Model { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
         protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { }
         protected virtual void Dispose(bool disposing) { }
         protected override void OnParametersSet() { }

+ 27 - 12
src/Components/Web/src/Forms/ValidationSummary.cs

@@ -19,6 +19,12 @@ namespace Microsoft.AspNetCore.Components.Forms
         private EditContext _previousEditContext;
         private readonly EventHandler<ValidationStateChangedEventArgs> _validationStateChangedHandler;
 
+        /// <summary>
+        /// Gets or sets the model to produce the list of validation messages for.
+        /// When specified, this lists all errors that are associated with the model instance.
+        /// </summary>
+        [Parameter] public object Model { get; set; }
+
         /// <summary>
         /// Gets or sets a collection of additional attributes that will be applied to the created <c>ul</c> element.
         /// </summary>
@@ -57,22 +63,31 @@ namespace Microsoft.AspNetCore.Components.Forms
         {
             // As an optimization, only evaluate the messages enumerable once, and
             // only produce the enclosing <ul> if there's at least one message
-            var messagesEnumerator = CurrentEditContext.GetValidationMessages().GetEnumerator();
-            if (messagesEnumerator.MoveNext())
-            {
-                builder.OpenElement(0, "ul");
-                builder.AddMultipleAttributes(1, AdditionalAttributes);
-                builder.AddAttribute(2, "class", "validation-errors");
+            var validationMessages = Model is null ?
+                CurrentEditContext.GetValidationMessages() :
+                CurrentEditContext.GetValidationMessages(new FieldIdentifier(Model, string.Empty));
 
-                do
+            var first = true;
+            foreach (var error in validationMessages)
+            {
+                if (first)
                 {
-                    builder.OpenElement(3, "li");
-                    builder.AddAttribute(4, "class", "validation-message");
-                    builder.AddContent(5, messagesEnumerator.Current);
-                    builder.CloseElement();
+                    first = false;
+
+                    builder.OpenElement(0, "ul");
+                    builder.AddMultipleAttributes(1, AdditionalAttributes);
+                    builder.AddAttribute(2, "class", "validation-errors");
                 }
-                while (messagesEnumerator.MoveNext());
 
+                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();
             }
         }

+ 57 - 19
src/Components/test/E2ETest/Tests/FormsTest.cs

@@ -1,18 +1,17 @@
 // Copyright (c) .NET Foundation. All rights reserved.
 // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
 
+using System;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
 using BasicTestApp;
 using BasicTestApp.FormsTest;
 using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
 using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
 using Microsoft.AspNetCore.E2ETesting;
-using Microsoft.AspNetCore.Testing;
 using OpenQA.Selenium;
 using OpenQA.Selenium.Support.UI;
-using System;
-using System.Linq;
-using System.Text.Json;
-using System.Threading.Tasks;
 using Xunit;
 using Xunit.Abstractions;
 
@@ -34,14 +33,20 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
             Navigate(ServerPathBase, noReload: _serverFixture.ExecutionMode == ExecutionMode.Client);
         }
 
+        protected virtual IWebElement MountSimpleValidationComponent()
+            => Browser.MountTestComponent<SimpleValidationComponent>();
+
+        protected virtual IWebElement MountTypicalValidationComponent()
+            => Browser.MountTestComponent<TypicalValidationComponent>();
+
         [Fact]
         public async Task EditFormWorksWithDataAnnotationsValidator()
         {
-            var appElement = Browser.MountTestComponent<SimpleValidationComponent>();
+            var appElement = MountSimpleValidationComponent();;
             var form = appElement.FindElement(By.TagName("form"));
             var userNameInput = appElement.FindElement(By.ClassName("user-name")).FindElement(By.TagName("input"));
             var acceptsTermsInput = appElement.FindElement(By.ClassName("accepts-terms")).FindElement(By.TagName("input"));
-            var submitButton = appElement.FindElement(By.TagName("button"));
+            var submitButton = appElement.FindElement(By.CssSelector("button[type=submit]"));
             var messagesAccessor = CreateValidationMessagesAccessor(appElement);
 
             // The form emits unmatched attributes
@@ -77,7 +82,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
         [Fact]
         public void InputTextInteractsWithEditContext()
         {
-            var appElement = Browser.MountTestComponent<TypicalValidationComponent>();
+            var appElement = MountTypicalValidationComponent();
             var nameInput = appElement.FindElement(By.ClassName("name")).FindElement(By.TagName("input"));
             var messagesAccessor = CreateValidationMessagesAccessor(appElement);
 
@@ -104,7 +109,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
         [Fact]
         public void InputNumberInteractsWithEditContext_NonNullableInt()
         {
-            var appElement = Browser.MountTestComponent<TypicalValidationComponent>();
+            var appElement = MountTypicalValidationComponent();
             var ageInput = appElement.FindElement(By.ClassName("age")).FindElement(By.TagName("input"));
             var messagesAccessor = CreateValidationMessagesAccessor(appElement);
 
@@ -136,7 +141,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
         [Fact]
         public void InputNumberInteractsWithEditContext_NullableFloat()
         {
-            var appElement = Browser.MountTestComponent<TypicalValidationComponent>();
+            var appElement = MountTypicalValidationComponent();
             var heightInput = appElement.FindElement(By.ClassName("height")).FindElement(By.TagName("input"));
             var messagesAccessor = CreateValidationMessagesAccessor(appElement);
 
@@ -160,7 +165,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
         [Fact]
         public void InputTextAreaInteractsWithEditContext()
         {
-            var appElement = Browser.MountTestComponent<TypicalValidationComponent>();
+            var appElement = MountTypicalValidationComponent();
             var descriptionInput = appElement.FindElement(By.ClassName("description")).FindElement(By.TagName("textarea"));
             var messagesAccessor = CreateValidationMessagesAccessor(appElement);
 
@@ -187,7 +192,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
         [Fact]
         public void InputDateInteractsWithEditContext_NonNullableDateTime()
         {
-            var appElement = Browser.MountTestComponent<TypicalValidationComponent>();
+            var appElement = MountTypicalValidationComponent();
             var renewalDateInput = appElement.FindElement(By.ClassName("renewal-date")).FindElement(By.TagName("input"));
             var messagesAccessor = CreateValidationMessagesAccessor(appElement);
 
@@ -218,7 +223,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
         [Fact]
         public void InputDateInteractsWithEditContext_NullableDateTimeOffset()
         {
-            var appElement = Browser.MountTestComponent<TypicalValidationComponent>();
+            var appElement = MountTypicalValidationComponent();
             var expiryDateInput = appElement.FindElement(By.ClassName("expiry-date")).FindElement(By.TagName("input"));
             var messagesAccessor = CreateValidationMessagesAccessor(appElement);
 
@@ -241,7 +246,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
         [Fact]
         public void InputSelectInteractsWithEditContext()
         {
-            var appElement = Browser.MountTestComponent<TypicalValidationComponent>();
+            var appElement = MountTypicalValidationComponent();
             var ticketClassInput = new SelectElement(appElement.FindElement(By.ClassName("ticket-class")).FindElement(By.TagName("select")));
             var select = ticketClassInput.WrappedElement;
             var messagesAccessor = CreateValidationMessagesAccessor(appElement);
@@ -263,7 +268,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
         [Fact]
         public void InputCheckboxInteractsWithEditContext()
         {
-            var appElement = Browser.MountTestComponent<TypicalValidationComponent>();
+            var appElement = MountTypicalValidationComponent();
             var acceptsTermsInput = appElement.FindElement(By.ClassName("accepts-terms")).FindElement(By.TagName("input"));
             var isEvilInput = appElement.FindElement(By.ClassName("is-evil")).FindElement(By.TagName("input"));
             var messagesAccessor = CreateValidationMessagesAccessor(appElement);
@@ -297,7 +302,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
             var appElement = Browser.MountTestComponent<NotifyPropertyChangedValidationComponent>();
             var userNameInput = appElement.FindElement(By.ClassName("user-name")).FindElement(By.TagName("input"));
             var acceptsTermsInput = appElement.FindElement(By.ClassName("accepts-terms")).FindElement(By.TagName("input"));
-            var submitButton = appElement.FindElement(By.TagName("button"));
+            var submitButton = appElement.FindElement(By.CssSelector("button[type=submit]"));
             var messagesAccessor = CreateValidationMessagesAccessor(appElement);
             var submissionStatus = appElement.FindElement(By.Id("submission-status"));
 
@@ -331,11 +336,11 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
         [Fact]
         public void ValidationMessageDisplaysMessagesForField()
         {
-            var appElement = Browser.MountTestComponent<TypicalValidationComponent>();
+            var appElement = MountTypicalValidationComponent();
             var emailContainer = appElement.FindElement(By.ClassName("email"));
             var emailInput = emailContainer.FindElement(By.TagName("input"));
             var emailMessagesAccessor = CreateValidationMessagesAccessor(emailContainer);
-            var submitButton = appElement.FindElement(By.TagName("button"));
+            var submitButton = appElement.FindElement(By.CssSelector("button[type=submit]"));
 
             // Doesn't show messages for other fields
             submitButton.Click();
@@ -355,10 +360,43 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
             Browser.Empty(emailMessagesAccessor);
         }
 
+        [Fact]
+        public void ErrorsFromCompareAttribute()
+        {
+            var appElement = MountTypicalValidationComponent();
+            var emailContainer = appElement.FindElement(By.ClassName("email"));
+            var emailInput = emailContainer.FindElement(By.TagName("input"));
+            var confirmEmailContainer = appElement.FindElement(By.ClassName("confirm-email"));
+            var confirmInput = confirmEmailContainer.FindElement(By.TagName("input"));
+            var confirmEmailValidationMessage = CreateValidationMessagesAccessor(confirmEmailContainer);
+            var modelErrors = CreateValidationMessagesAccessor(appElement.FindElement(By.ClassName("model-errors")));
+            CreateValidationMessagesAccessor(emailContainer);
+            var submitButton = appElement.FindElement(By.CssSelector("button[type=submit]"));
+
+            // Updates on edit
+            emailInput.SendKeys("[email protected]\t");
+
+            submitButton.Click();
+            Browser.Empty(confirmEmailValidationMessage);
+            Browser.Equal(new[] { "Email and confirm email do not match." }, modelErrors);
+
+            confirmInput.SendKeys("[email protected]\t");
+            Browser.Equal(new[] { "Email and confirm email do not match." }, confirmEmailValidationMessage);
+
+            // Can become correct
+            confirmInput.Clear();
+            confirmInput.SendKeys("[email protected]\t");
+
+            Browser.Empty(confirmEmailValidationMessage);
+
+            submitButton.Click();
+            Browser.Empty(modelErrors);
+        }
+
         [Fact]
         public void InputComponentsCauseContainerToRerenderOnChange()
         {
-            var appElement = Browser.MountTestComponent<TypicalValidationComponent>();
+            var appElement = MountTypicalValidationComponent();
             var ticketClassInput = new SelectElement(appElement.FindElement(By.ClassName("ticket-class")).FindElement(By.TagName("select")));
             var selectedTicketClassDisplay = appElement.FindElement(By.Id("selected-ticket-class"));
             var messagesAccessor = CreateValidationMessagesAccessor(appElement);

+ 90 - 0
src/Components/test/E2ETest/Tests/FormsTestWithExperimentalValidator.cs

@@ -0,0 +1,90 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using BasicTestApp.FormsTest;
+using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
+using Microsoft.AspNetCore.E2ETesting;
+using OpenQA.Selenium;
+using OpenQA.Selenium.Support.UI;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Microsoft.AspNetCore.Components.E2ETest.Tests
+{
+    public class FormsTestWithExperimentalValidator : FormsTest
+    {
+        public FormsTestWithExperimentalValidator(
+            BrowserFixture browserFixture,
+            ToggleExecutionModeServerFixture<BasicTestApp.Program> serverFixture,
+            ITestOutputHelper output) : base(browserFixture, serverFixture, output)
+        {
+        }
+
+        protected override IWebElement MountSimpleValidationComponent()
+            => Browser.MountTestComponent<SimpleValidationComponentUsingExperimentalValidator>();
+
+        protected override IWebElement MountTypicalValidationComponent()
+            => Browser.MountTestComponent<TypicalValidationComponentUsingExperimentalValidator>();
+
+        [Fact]
+        public void EditFormWorksWithNestedValidation()
+        {
+            var appElement = Browser.MountTestComponent<ExperimentalValidationComponent>();
+
+            var nameInput = appElement.FindElement(By.CssSelector(".name input"));
+            var emailInput = appElement.FindElement(By.CssSelector(".email input"));
+            var confirmEmailInput = appElement.FindElement(By.CssSelector(".confirm-email input"));
+            var streetInput = appElement.FindElement(By.CssSelector(".street input"));
+            var zipInput = appElement.FindElement(By.CssSelector(".zip input"));
+            var countryInput = new SelectElement(appElement.FindElement(By.CssSelector(".country select")));
+            var descriptionInput = appElement.FindElement(By.CssSelector(".description input"));
+            var weightInput = appElement.FindElement(By.CssSelector(".weight input"));
+
+            var submitButton = appElement.FindElement(By.CssSelector("button[type=submit]"));
+
+            submitButton.Click();
+
+            Browser.Equal(4, () => appElement.FindElements(By.CssSelector(".all-errors .validation-message")).Count);
+
+            Browser.Equal("Enter a name", () => appElement.FindElement(By.CssSelector(".name .validation-message")).Text);
+            Browser.Equal("Enter an email", () => appElement.FindElement(By.CssSelector(".email .validation-message")).Text);
+            Browser.Equal("A street address is required.", () => appElement.FindElement(By.CssSelector(".street .validation-message")).Text);
+            Browser.Equal("Description is required.", () => appElement.FindElement(By.CssSelector(".description .validation-message")).Text);
+
+            // Verify class-level validation
+            nameInput.SendKeys("Some person");
+            emailInput.SendKeys("[email protected]");
+            countryInput.SelectByValue("Mordor");
+            descriptionInput.SendKeys("Fragile staff");
+            streetInput.SendKeys("Mount Doom\t");
+
+            submitButton.Click();
+
+            // Verify member validation from IValidatableObject on a model property, CustomValidationAttribute on a model attribute, and BlazorCompareAttribute.
+            Browser.Equal("A ZipCode is required", () => appElement.FindElement(By.CssSelector(".zip .validation-message")).Text);
+            Browser.Equal("'Confirm email address' and 'EmailAddress' do not match.", () => appElement.FindElement(By.CssSelector(".confirm-email .validation-message")).Text);
+            Browser.Equal("Fragile items must be placed in secure containers", () => appElement.FindElement(By.CssSelector(".item-error .validation-message")).Text);
+            Browser.Equal(3, () => appElement.FindElements(By.CssSelector(".all-errors .validation-message")).Count);
+
+            zipInput.SendKeys("98052");
+            confirmEmailInput.SendKeys("[email protected]");
+            descriptionInput.Clear();
+            weightInput.SendKeys("0");
+            descriptionInput.SendKeys("The One Ring\t");
+
+            submitButton.Click();
+            // Verify validation from IValidatableObject on the model.
+            Browser.Equal("Some items in your list cannot be delivered.", () => appElement.FindElement(By.CssSelector(".model-errors .validation-message")).Text);
+
+            Browser.Single(() => appElement.FindElements(By.CssSelector(".all-errors .validation-message")));
+
+            // Let's make sure the form submits
+            descriptionInput.Clear();
+            descriptionInput.SendKeys("A different ring\t");
+            submitButton.Click();
+
+            Browser.Empty(() => appElement.FindElements(By.CssSelector(".all-errors .validation-message")));
+            Browser.Equal("OnValidSubmit", () => appElement.FindElement(By.CssSelector(".submission-log")).Text);
+        }
+    }
+}

+ 1 - 0
src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj

@@ -17,6 +17,7 @@
     <Reference Include="Microsoft.AspNetCore.Blazor" />
     <Reference Include="Microsoft.AspNetCore.Blazor.HttpClient" />
     <Reference Include="Microsoft.AspNetCore.Components.Authorization" />
+    <Reference Include="Microsoft.AspNetCore.Blazor.DataAnnotations.Validation" />
   </ItemGroup>
 
   <ItemGroup>

+ 185 - 0
src/Components/test/testassets/BasicTestApp/FormsTest/ExperimentalValidationComponent.razor

@@ -0,0 +1,185 @@
+@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Components.Forms
+
+    <p>
+        This component is used to verify the use of the experimental ObjectGraphDataAnnotationsValidator type with IValidatableObject and deep validation, as well
+        as the ComparePropertyAttribute.
+    </p>
+
+<EditForm Model="@model" OnValidSubmit="@HandleValidSubmit">
+    <ObjectGraphDataAnnotationsValidator />
+
+    <p class="name">
+        Name: <InputText @bind-Value="model.Recipient" placeholder="Enter the recipient" />
+        <ValidationMessage For="@(() => model.Recipient)" />
+    </p>
+
+    <p class="email">
+        Email: <InputText @bind-Value="model.EmailAddress" />
+        <ValidationMessage For="@(() => model.EmailAddress)" />
+    </p>
+
+    <p class="confirm-email">
+        Confirm Email: <InputText @bind-Value="model.ConfirmEmailAddress" />
+        <ValidationMessage For="@(() => model.ConfirmEmailAddress)" />
+    </p>
+
+    <fieldset>
+        <legend>Items to deliver</legend>
+        <p>
+            <button id="addItem" type="button" @onclick="AddItem">Add Item</button>
+        </p>
+        <ul class="items">
+        @foreach (var item in model.Items)
+        {
+            <li>
+                <div  style="display: inline-flex; flex-direction: row">
+                    <div style="flex-grow: 1" class="description">
+                        <InputText @bind-Value="item.Description" placeholder="Description" />
+                        <ValidationMessage For="@(() => item.Description)" />
+                    </div>
+
+                    <div style="flex-grow: 1" class="weight">
+                        <InputNumber @bind-Value="item.Weight" />
+                        <ValidationMessage For="@(() => item.Weight)" />
+                    </div>
+                    <div style="flex-grow: 1" class="item-error">
+                        <ValidationSummary Model="item" />
+                    </div>
+                </div>
+            </li>
+        }
+        </ul>
+    </fieldset>
+
+    <fieldset>
+        <legend>Shipping details</legend>
+        <p class="street">
+            Street Address: <InputText @bind-Value="model.Address.Street" />
+            <ValidationMessage For="@(() => model.Address.Street)" />
+        </p>
+        <p class="zip">
+            Zip Code: <InputText @bind-Value="model.Address.ZipCode" />
+            <ValidationMessage For="@(() => model.Address.ZipCode)" />
+        </p>
+        <p class="country">
+            Country:
+            <InputSelect @bind-Value="model.Address.Country">
+                <option></option>
+                <option value="@Country.Gondor">@Country.Gondor</option>
+                <option value="@Country.Mordor">@Country.Mordor</option>
+                <option value="@Country.Rohan">@Country.Rohan</option>
+                <option value="@Country.Shire">@Country.Shire</option>
+            </InputSelect>
+            <ValidationMessage For="@(() => model.Address.Country)" />
+        </p>
+        <p class="address-validation">
+            <ValidationSummary Model="model.Address" />
+        </p>
+    </fieldset>
+
+    <div class="model-errors">
+        <ValidationSummary Model="model"/>
+    </div>
+    <button type="submit">Submit</button>
+
+    <div class="all-errors">
+        <ValidationSummary />
+    </div>
+
+</EditForm>
+
+<ul class="submission-log">
+    @foreach (var entry in submissionLog)
+    {
+        <li>@entry</li>
+    }
+</ul>
+
+@code {
+    Delivery model = new Delivery();
+
+    public class Delivery : IValidatableObject
+    {
+        [Required(ErrorMessage = "Enter a name")]
+        public string Recipient { get; set; }
+
+        [Required(ErrorMessage = "Enter an email")]
+        [EmailAddress(ErrorMessage = "Enter a valid email address")]
+        public string EmailAddress { get; set; }
+
+        [CompareProperty(nameof(EmailAddress))]
+        [Display(Name = "Confirm email address")]
+        public string ConfirmEmailAddress { get; set; }
+
+        [ValidateComplexType]
+        public Address Address { get; } = new Address();
+
+        [ValidateComplexType]
+        public List<Item> Items { get; } = new List<Item>
+        {
+            new Item(),
+        };
+
+        public IEnumerable<ValidationResult> Validate(ValidationContext context)
+        {
+            if (Address.Street == "Mount Doom" && Items.Any(i => i.Description == "The One Ring"))
+            {
+                yield return new ValidationResult("Some items in your list cannot be delivered.");
+            }
+        }
+    }
+
+    public class Address : IValidatableObject
+    {
+        [Required(ErrorMessage = "A street address is required.")]
+        public string Street { get; set; }
+
+        public string ZipCode { get; set; }
+
+        [EnumDataType(typeof(Country))]
+        public Country Country { get; set; }
+
+        public IEnumerable<ValidationResult> Validate(ValidationContext context)
+        {
+            if (Country == Country.Mordor && string.IsNullOrEmpty(ZipCode))
+            {
+                yield return new ValidationResult("A ZipCode is required", new[] { nameof(ZipCode) });
+            }
+        }
+    }
+
+    [CustomValidation(typeof(Item), nameof(Item.CustomValidate))]
+    public class Item
+    {
+        [Required(ErrorMessage = "Description is required.")]
+        public string Description { get; set; }
+
+        [Range(0.1, 50, ErrorMessage = "Items must weigh between 0.1 and 5")]
+        public double Weight { get; set; } = 1;
+
+        public static ValidationResult CustomValidate(Item item, ValidationContext context)
+        {
+            if (item.Weight < 2.0 && item.Description.StartsWith("Fragile"))
+            {
+                return new ValidationResult("Fragile items must be placed in secure containers");
+            }
+
+            return ValidationResult.Success;
+        }
+    }
+
+    public enum Country { Gondor, Mordor, Rohan, Shire }
+
+    List<string> submissionLog = new List<string>();
+
+    void HandleValidSubmit()
+    {
+        submissionLog.Add("OnValidSubmit");
+    }
+
+    void AddItem()
+    {
+        model.Items.Add(new Item());
+    }
+}

+ 10 - 1
src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.razor

@@ -2,7 +2,14 @@
 @using Microsoft.AspNetCore.Components.Forms
 
 <EditForm Model="@this" OnValidSubmit="@HandleValidSubmit" OnInvalidSubmit="@HandleInvalidSubmit" autocomplete="off">
-    <DataAnnotationsValidator />
+    @if (UseExperimentalValidator)
+    {
+        <ObjectGraphDataAnnotationsValidator />
+    }
+    else
+    {
+        <DataAnnotationsValidator />
+    }
 
     <p class="user-name">
         User name: <input @bind="UserName" class="@context.FieldCssClass(() => UserName)" />
@@ -29,6 +36,8 @@
 }
 
 @code {
+    protected virtual bool UseExperimentalValidator => false;
+
     string lastCallback;
 
     [Required(ErrorMessage = "Please choose a username")]

+ 7 - 0
src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponentUsingExperimentalValidator.cs

@@ -0,0 +1,7 @@
+namespace BasicTestApp.FormsTest
+{
+    public class TypicalValidationComponentUsingExperimentalValidator : TypicalValidationComponent
+    {
+        protected override bool UseExperimentalValidator => true;
+    }
+}

+ 20 - 0
src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor

@@ -2,7 +2,14 @@
 @using Microsoft.AspNetCore.Components.Forms
 
 <EditForm EditContext="@editContext" OnValidSubmit="@HandleValidSubmit">
+    @if (UseExperimentalValidator)
+    {
+        <ObjectGraphDataAnnotationsValidator />
+    }
+    else
+    {
     <DataAnnotationsValidator />
+    }
 
     <p class="name">
         Name: <InputText @bind-Value="person.Name" placeholder="Enter your name" />
@@ -11,6 +18,10 @@
         Email: <InputText @bind-Value="person.Email" />
         <ValidationMessage For="@(() => person.Email)" />
     </p>
+    <p class="confirm-email">
+        Email: <InputText @bind-Value="person.ConfirmEmail" />
+        <ValidationMessage For="@(() => person.ConfirmEmail)" />
+    </p>
     <p class="age">
         Age (years): <InputNumber @bind-Value="person.AgeInYears" placeholder="Enter your age" />
     </p>
@@ -49,12 +60,18 @@
 
     <button type="submit">Submit</button>
 
+    <p class="model-errors">
+        <ValidationSummary Model="person" />
+    </p>
+
     <ValidationSummary />
 </EditForm>
 
 <ul>@foreach (var entry in submissionLog) { <li>@entry</li> }</ul>
 
 @code {
+    protected virtual bool UseExperimentalValidator => false;
+
     Person person = new Person();
     EditContext editContext;
     ValidationMessageStore customValidationMessageStore;
@@ -75,6 +92,9 @@
         [StringLength(10, ErrorMessage = "We only accept very short email addresses (max 10 chars)")]
         public string Email { get; set; }
 
+        [Compare(nameof(Email), ErrorMessage = "Email and confirm email do not match.")]
+        public string ConfirmEmail { get; set; }
+
         [Range(0, 200, ErrorMessage = "Nobody is that old")]
         public int AgeInYears { get; set; }
 

+ 7 - 0
src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponentUsingExperimentalValidator.cs

@@ -0,0 +1,7 @@
+namespace BasicTestApp.FormsTest
+{
+    public class SimpleValidationComponentUsingExperimentalValidator : SimpleValidationComponent
+    {
+        protected override bool UseExperimentalValidator => true;
+    }
+}

+ 3 - 0
src/Components/test/testassets/BasicTestApp/Index.razor

@@ -29,7 +29,10 @@
         <option value="BasicTestApp.FocusEventComponent">Focus events</option>
         <option value="BasicTestApp.FormsTest.NotifyPropertyChangedValidationComponent">INotifyPropertyChanged validation</option>
         <option value="BasicTestApp.FormsTest.SimpleValidationComponent">Simple validation</option>
+        <option value="BasicTestApp.FormsTest.SimpleValidationComponentUsingExperimentalValidator">Simple validation using experimental validator</option>
         <option value="BasicTestApp.FormsTest.TypicalValidationComponent">Typical validation</option>
+        <option value="BasicTestApp.FormsTest.TypicalValidationComponentUsingExperimentalValidator">Typical validation using experimental validator</option>
+        <option value="BasicTestApp.FormsTest.ExperimentalValidationComponent">Experimental validation</option>
         <option value="BasicTestApp.GlobalizationBindCases">Globalization Bind Cases</option>
         <option value="BasicTestApp.HierarchicalImportsTest.Subdir.ComponentUsingImports">Imports statement</option>
         <option value="BasicTestApp.HtmlBlockChildContent">ChildContent HTML Block</option>

+ 24 - 7
src/Shared/E2ETesting/BrowserAssertFailedException.cs

@@ -4,6 +4,7 @@
 using System;
 using System.Collections.Generic;
 using System.IO;
+using System.Text;
 using Xunit.Sdk;
 
 namespace OpenQA.Selenium
@@ -13,15 +14,31 @@ namespace OpenQA.Selenium
     // case.
     public class BrowserAssertFailedException : XunitException
     {
-        public BrowserAssertFailedException(IReadOnlyList<LogEntry> logs, Exception innerException, string screenShotPath)
-            : base(BuildMessage(innerException, logs, screenShotPath), innerException)
+        public BrowserAssertFailedException(IReadOnlyList<LogEntry> logs, Exception innerException, string screenShotPath, string innerHTML)
+            : base(BuildMessage(innerException, logs, screenShotPath, innerHTML), innerException)
         {
         }
 
-        private static string BuildMessage(Exception innerException, IReadOnlyList<LogEntry> logs, string screenShotPath) =>
-            innerException.ToString() + Environment.NewLine +
-            (File.Exists(screenShotPath) ? $"Screen shot captured at '{screenShotPath}'" + Environment.NewLine : "") +
-            (logs.Count > 0 ? "Encountered browser logs" : "No browser logs found") + " while running the assertion." + Environment.NewLine +
-            string.Join(Environment.NewLine, logs);
+        private static string BuildMessage(Exception exception, IReadOnlyList<LogEntry> logs, string screenShotPath, string innerHTML)
+        {
+            var builder = new StringBuilder();
+            builder.AppendLine(exception.ToString());
+
+            if (File.Exists(screenShotPath))
+            {
+                builder.AppendLine($"Screen shot captured at '{screenShotPath}'");
+            }
+
+            if (logs.Count > 0)
+            {
+                builder.AppendLine("Encountered browser errors")
+                    .AppendJoin(Environment.NewLine, logs);
+            }
+
+            builder.AppendLine("Page content:")
+               .AppendLine(innerHTML);
+
+            return builder.ToString();
+        }
     }
 }

+ 3 - 1
src/Shared/E2ETesting/WaitAssert.cs

@@ -101,6 +101,8 @@ namespace Microsoft.AspNetCore.E2ETesting
                 // tests running concurrently might use the DefaultTimeout in their current assertion, which is fine.
                 TestRunFailed = true;
 
+                var innerHtml = driver.FindElement(By.CssSelector(":first-child")).GetAttribute("innerHTML");
+
                 var fileId = $"{Guid.NewGuid():N}.png";
                 var screenShotPath = Path.Combine(Path.GetFullPath(E2ETestOptions.Instance.ScreenShotsPath), fileId);
                 var errors = driver.GetBrowserLogs(LogLevel.All);
@@ -109,7 +111,7 @@ namespace Microsoft.AspNetCore.E2ETesting
                 var exceptionInfo = lastException != null ? ExceptionDispatchInfo.Capture(lastException) :
                     CaptureException(() => assertion());
 
-                throw new BrowserAssertFailedException(errors, exceptionInfo.SourceException, screenShotPath);
+                throw new BrowserAssertFailedException(errors, exceptionInfo.SourceException, screenShotPath, innerHtml);
             }
 
             return result;

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini