diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props index 4210405c67..3ac3e8dbf6 100644 --- a/eng/ProjectReferences.props +++ b/eng/ProjectReferences.props @@ -15,6 +15,7 @@ + diff --git a/src/Components/Blazor/Validation/src/ComparePropertyAttribute.cs b/src/Components/Blazor/Validation/src/ComparePropertyAttribute.cs new file mode 100644 index 0000000000..3f74ce647f --- /dev/null +++ b/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 +{ + /// + /// A that compares two properties + /// + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] + public sealed class ComparePropertyAttribute : CompareAttribute + { + /// + /// Initializes a new instance of . + /// + /// The property to compare with the current property. + public ComparePropertyAttribute(string otherProperty) + : base(otherProperty) + { + } + + /// + 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 }); + } + } +} + diff --git a/src/Components/Blazor/Validation/src/Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.csproj b/src/Components/Blazor/Validation/src/Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.csproj new file mode 100644 index 0000000000..a166d5f1f3 --- /dev/null +++ b/src/Components/Blazor/Validation/src/Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.csproj @@ -0,0 +1,18 @@ + + + + netstandard2.0 + Provides experimental support for validation using DataAnnotations. + true + false + + + + + + + + + + + diff --git a/src/Components/Blazor/Validation/src/ObjectGraphDataAnnotationsValidator.cs b/src/Components/Blazor/Validation/src/ObjectGraphDataAnnotationsValidator.cs new file mode 100644 index 0000000000..df1971e0a2 --- /dev/null +++ b/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()); + 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 visited) + { + if (value is null) + { + return; + } + + if (!visited.Add(value)) + { + // Already visited this object. + return; + } + + if (value is IEnumerable enumerable) + { + var index = 0; + foreach (var item in enumerable) + { + ValidateObject(item, visited); + index++; + } + + return; + } + + var validationResults = new List(); + 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 visited, List 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)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(); + + 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(); + } + } + } +} diff --git a/src/Components/Blazor/Validation/src/ValidateComplexTypeAttribute.cs b/src/Components/Blazor/Validation/src/ValidateComplexTypeAttribute.cs new file mode 100644 index 0000000000..4769d84767 --- /dev/null +++ b/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 +{ + /// + /// A that indicates that the property is a complex or collection type that further needs to be validated. + /// + /// By default does not recurse in to complex property types during validation. + /// When used in conjunction with , this property allows the validation system to validate + /// complex or collection type properties. + /// + /// + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] + public sealed class ValidateComplexTypeAttribute : ValidationAttribute + { + /// + 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; + } + } +} diff --git a/src/Components/Blazor/Validation/test/Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.Tests.csproj b/src/Components/Blazor/Validation/test/Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.Tests.csproj new file mode 100644 index 0000000000..02e8561536 --- /dev/null +++ b/src/Components/Blazor/Validation/test/Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.Tests.csproj @@ -0,0 +1,11 @@ + + + + $(DefaultNetCoreTargetFramework) + + + + + + + diff --git a/src/Components/Blazor/Validation/test/ObjectGraphDataAnnotationsValidatorTest.cs b/src/Components/Blazor/Validation/test/ObjectGraphDataAnnotationsValidatorTest.cs new file mode 100644 index 0000000000..6703eb35d5 --- /dev/null +++ b/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 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 + { + 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 + { + 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 Related { get; set; } = new List(); + } + + [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(); + } + } +} diff --git a/src/Components/Components.sln b/src/Components/Components.sln index 046fc0b7ca..ba0b2476ff 100644 --- a/src/Components/Components.sln +++ b/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} diff --git a/src/Components/ComponentsNoDeps.slnf b/src/Components/ComponentsNoDeps.slnf index ce01e6468f..09f6a0859f 100644 --- a/src/Components/ComponentsNoDeps.slnf +++ b/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", diff --git a/src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs b/src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs index 9fce473a41..e7f9d5f155 100644 --- a/src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs +++ b/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); diff --git a/src/Components/Samples/BlazorServerApp/BlazorServerApp.csproj b/src/Components/Samples/BlazorServerApp/BlazorServerApp.csproj index 101fe45c13..2a82b7453a 100644 --- a/src/Components/Samples/BlazorServerApp/BlazorServerApp.csproj +++ b/src/Components/Samples/BlazorServerApp/BlazorServerApp.csproj @@ -12,6 +12,7 @@ + diff --git a/src/Components/Web.JS/dist/Release/blazor.server.js b/src/Components/Web.JS/dist/Release/blazor.server.js index 3b085c3f0c..3a2abaced9 100644 --- a/src/Components/Web.JS/dist/Release/blazor.server.js +++ b/src/Components/Web.JS/dist/Release/blazor.server.js @@ -1,4 +1,4 @@ -!function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=50)}([function(e,t,n){"use strict";var r;n.d(t,"a",function(){return r}),function(e){e[e.Trace=0]="Trace",e[e.Debug=1]="Debug",e[e.Information=2]="Information",e[e.Warning=3]="Warning",e[e.Error=4]="Error",e[e.Critical=5]="Critical",e[e.None=6]="None"}(r||(r={}))},function(e,t,n){"use strict";n.d(t,"a",function(){return s}),n.d(t,"c",function(){return c}),n.d(t,"f",function(){return u}),n.d(t,"g",function(){return l}),n.d(t,"h",function(){return f}),n.d(t,"e",function(){return h}),n.d(t,"d",function(){return p}),n.d(t,"b",function(){return d});var r=n(0),o=n(7),i=function(e,t,n,r){return new(n||(n=Promise))(function(o,i){function a(e){try{c(r.next(e))}catch(e){i(e)}}function s(e){try{c(r.throw(e))}catch(e){i(e)}}function c(e){e.done?o(e.value):new n(function(t){t(e.value)}).then(a,s)}c((r=r.apply(e,t||[])).next())})},a=function(e,t){var n,r,o,i,a={label:0,sent:function(){if(1&o[0])throw o[1];return o[1]},trys:[],ops:[]};return i={next:s(0),throw:s(1),return:s(2)},"function"==typeof Symbol&&(i[Symbol.iterator]=function(){return this}),i;function s(i){return function(s){return function(i){if(n)throw new TypeError("Generator is already executing.");for(;a;)try{if(n=1,r&&(o=2&i[0]?r.return:i[0]?r.throw||((o=r.return)&&o.call(r),0):r.next)&&!(o=o.call(r,i[1])).done)return o;switch(r=0,o&&(i=[2&i[0],o.value]),i[0]){case 0:case 1:o=i;break;case 4:return a.label++,{value:i[1],done:!1};case 5:a.label++,r=i[1],i=[0];continue;case 7:i=a.ops.pop(),a.trys.pop();continue;default:if(!(o=(o=a.trys).length>0&&o[o.length-1])&&(6===i[0]||2===i[0])){a=0;continue}if(3===i[0]&&(!o||i[1]>o[0]&&i[1]-1&&this.subject.observers.splice(e,1),0===this.subject.observers.length&&this.subject.cancelCallback&&this.subject.cancelCallback().catch(function(e){})},e}(),d=function(){function e(e){this.minimumLogLevel=e,this.outputConsole=console}return e.prototype.log=function(e,t){if(e>=this.minimumLogLevel)switch(e){case r.a.Critical:case r.a.Error:this.outputConsole.error("["+(new Date).toISOString()+"] "+r.a[e]+": "+t);break;case r.a.Warning:this.outputConsole.warn("["+(new Date).toISOString()+"] "+r.a[e]+": "+t);break;case r.a.Information:this.outputConsole.info("["+(new Date).toISOString()+"] "+r.a[e]+": "+t);break;default:this.outputConsole.log("["+(new Date).toISOString()+"] "+r.a[e]+": "+t)}},e}()},function(e,t,n){"use strict";n.r(t);var r,o,i=n(3),a=n(4),s=n(44),c=n(0),u=(r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var n in t)t.hasOwnProperty(n)&&(e[n]=t[n])},function(e,t){function n(){this.constructor=e}r(e,t),e.prototype=null===t?Object.create(t):(n.prototype=t.prototype,new n)}),l=function(e){function t(t){var n=e.call(this)||this;return n.logger=t,n}return u(t,e),t.prototype.send=function(e){var t=this;return e.abortSignal&&e.abortSignal.aborted?Promise.reject(new i.a):e.method?e.url?new Promise(function(n,r){var o=new XMLHttpRequest;o.open(e.method,e.url,!0),o.withCredentials=!0,o.setRequestHeader("X-Requested-With","XMLHttpRequest"),o.setRequestHeader("Content-Type","text/plain;charset=UTF-8");var s=e.headers;s&&Object.keys(s).forEach(function(e){o.setRequestHeader(e,s[e])}),e.responseType&&(o.responseType=e.responseType),e.abortSignal&&(e.abortSignal.onabort=function(){o.abort(),r(new i.a)}),e.timeout&&(o.timeout=e.timeout),o.onload=function(){e.abortSignal&&(e.abortSignal.onabort=null),o.status>=200&&o.status<300?n(new a.b(o.status,o.statusText,o.response||o.responseText)):r(new i.b(o.statusText,o.status))},o.onerror=function(){t.logger.log(c.a.Warning,"Error from HTTP request. "+o.status+": "+o.statusText+"."),r(new i.b(o.statusText,o.status))},o.ontimeout=function(){t.logger.log(c.a.Warning,"Timeout from HTTP request."),r(new i.c)},o.send(e.content||"")}):Promise.reject(new Error("No url defined.")):Promise.reject(new Error("No method defined."))},t}(a.a),f=function(){var e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var n in t)t.hasOwnProperty(n)&&(e[n]=t[n])};return function(t,n){function r(){this.constructor=t}e(t,n),t.prototype=null===n?Object.create(n):(r.prototype=n.prototype,new r)}}(),h=function(e){function t(t){var n=e.call(this)||this;return"undefined"!=typeof XMLHttpRequest?n.httpClient=new l(t):n.httpClient=new s.a(t),n}return f(t,e),t.prototype.send=function(e){return e.abortSignal&&e.abortSignal.aborted?Promise.reject(new i.a):e.method?e.url?this.httpClient.send(e):Promise.reject(new Error("No url defined.")):Promise.reject(new Error("No method defined."))},t.prototype.getCookieString=function(e){return this.httpClient.getCookieString(e)},t}(a.a),p=n(45);!function(e){e[e.Invocation=1]="Invocation",e[e.StreamItem=2]="StreamItem",e[e.Completion=3]="Completion",e[e.StreamInvocation=4]="StreamInvocation",e[e.CancelInvocation=5]="CancelInvocation",e[e.Ping=6]="Ping",e[e.Close=7]="Close"}(o||(o={}));var d,g=n(1),y=function(){function e(){this.observers=[]}return e.prototype.next=function(e){for(var t=0,n=this.observers;t0&&o[o.length-1])&&(6===i[0]||2===i[0])){a=0;continue}if(3===i[0]&&(!o||i[1]>o[0]&&i[1]0&&o[o.length-1])&&(6===i[0]||2===i[0])){a=0;continue}if(3===i[0]&&(!o||i[1]>o[0]&&i[1]0&&o[o.length-1])&&(6===i[0]||2===i[0])){a=0;continue}if(3===i[0]&&(!o||i[1]>o[0]&&i[1]0&&o[o.length-1])&&(6===i[0]||2===i[0])){a=0;continue}if(3===i[0]&&(!o||i[1]>o[0]&&i[1]0&&o[o.length-1])&&(6===i[0]||2===i[0])){a=0;continue}if(3===i[0]&&(!o||i[1]>o[0]&&i[1]0?[2,Promise.reject(new Error("Unable to connect to the server with any of the available transports. "+i.join(" ")))]:[2,Promise.reject(new Error("None of the transports supported by the client are supported by the server."))]}})})},e.prototype.constructTransport=function(e){switch(e){case E.WebSockets:if(!this.options.WebSocket)throw new Error("'WebSocket' is not supported in your environment.");return new A(this.httpClient,this.accessTokenFactory,this.logger,this.options.logMessageContent||!1,this.options.WebSocket);case E.ServerSentEvents:if(!this.options.EventSource)throw new Error("'EventSource' is not supported in your environment.");return new O(this.httpClient,this.accessTokenFactory,this.logger,this.options.logMessageContent||!1,this.options.EventSource);case E.LongPolling:return new P(this.httpClient,this.accessTokenFactory,this.logger,this.options.logMessageContent||!1);default:throw new Error("Unknown transport: "+e+".")}},e.prototype.startTransport=function(e,t){var n=this;return this.transport.onreceive=this.onreceive,this.transport.onclose=function(e){return n.stopConnection(e)},this.transport.connect(e,t)},e.prototype.resolveTransportOrError=function(e,t,n){var r=E[e.transport];if(null==r)return this.logger.log(c.a.Debug,"Skipping transport '"+e.transport+"' because it is not supported by this client."),new Error("Skipping transport '"+e.transport+"' because it is not supported by this client.");if(!function(e,t){return!e||0!=(t&e)}(t,r))return this.logger.log(c.a.Debug,"Skipping transport '"+E[r]+"' because it was disabled by the client."),new Error("'"+E[r]+"' is disabled by the client.");if(!(e.transferFormats.map(function(e){return S[e]}).indexOf(n)>=0))return this.logger.log(c.a.Debug,"Skipping transport '"+E[r]+"' because it does not support the requested transfer format '"+S[n]+"'."),new Error("'"+E[r]+"' does not support "+S[n]+".");if(r===E.WebSockets&&!this.options.WebSocket||r===E.ServerSentEvents&&!this.options.EventSource)return this.logger.log(c.a.Debug,"Skipping transport '"+E[r]+"' because it is not supported in your environment.'"),new Error("'"+E[r]+"' is not supported in your environment.");this.logger.log(c.a.Debug,"Selecting transport '"+E[r]+"'.");try{return this.constructTransport(r)}catch(e){return e}},e.prototype.isITransport=function(e){return e&&"object"==typeof e&&"connect"in e},e.prototype.stopConnection=function(e){if(this.logger.log(c.a.Debug,"HttpConnection.stopConnection("+e+") called while in state "+this.connectionState+"."),this.transport=void 0,e=this.stopError||e,this.stopError=void 0,"Disconnected"!==this.connectionState)if("Connecting "!==this.connectionState){if("Disconnecting"===this.connectionState&&this.stopPromiseResolver(),e?this.logger.log(c.a.Error,"Connection disconnected with error '"+e+"'."):this.logger.log(c.a.Information,"Connection disconnected."),this.connectionId=void 0,this.connectionState="Disconnected",this.onclose&&this.connectionStarted){this.connectionStarted=!1;try{this.onclose(e)}catch(t){this.logger.log(c.a.Error,"HttpConnection.onclose("+e+") threw error '"+t+"'.")}}}else this.logger.log(c.a.Warning,"Call to HttpConnection.stopConnection("+e+") was ignored because the connection hasn't yet left the in the connecting state.");else this.logger.log(c.a.Debug,"Call to HttpConnection.stopConnection("+e+") was ignored because the connection is already in the disconnected state.")},e.prototype.resolveUrl=function(e){if(0===e.lastIndexOf("https://",0)||0===e.lastIndexOf("http://",0))return e;if(!g.c.isBrowser||!window.document)throw new Error("Cannot resolve '"+e+"'.");var t=window.document.createElement("a");return t.href=e,this.logger.log(c.a.Information,"Normalizing '"+e+"' to '"+t.href+"'."),t.href},e.prototype.resolveNegotiateUrl=function(e){var t=e.indexOf("?"),n=e.substring(0,-1===t?e.length:t);return"/"!==n[n.length-1]&&(n+="/"),n+="negotiate",-1===(n+=-1===t?"":e.substring(t)).indexOf("negotiateVersion")&&(n+=-1===t?"?":"&",n+="negotiateVersion="+this.negotiateVersion),n},e}();var q=function(){function e(e){this.transport=e,this.buffer=[],this.executing=!0,this.sendBufferedData=new W,this.transportResult=new W,this.sendLoopPromise=this.sendLoop()}return e.prototype.send=function(e){return this.bufferData(e),this.transportResult||(this.transportResult=new W),this.transportResult.promise},e.prototype.stop=function(){return this.executing=!1,this.sendBufferedData.resolve(),this.sendLoopPromise},e.prototype.bufferData=function(e){if(this.buffer.length&&typeof this.buffer[0]!=typeof e)throw new Error("Expected data to be of type "+typeof this.buffer+" but was of type "+typeof e);this.buffer.push(e),this.sendBufferedData.resolve()},e.prototype.sendLoop=function(){return B(this,void 0,void 0,function(){var t,n,r;return j(this,function(o){switch(o.label){case 0:return[4,this.sendBufferedData.promise];case 1:if(o.sent(),!this.executing)return this.transportResult&&this.transportResult.reject("Connection stopped."),[3,6];this.sendBufferedData=new W,t=this.transportResult,this.transportResult=void 0,n="string"==typeof this.buffer[0]?this.buffer.join(""):e.concatBuffers(this.buffer),this.buffer.length=0,o.label=2;case 2:return o.trys.push([2,4,,5]),[4,this.transport.send(n)];case 3:return o.sent(),t.resolve(),[3,5];case 4:return r=o.sent(),t.reject(r),[3,5];case 5:return[3,0];case 6:return[2]}})})},e.concatBuffers=function(e){for(var t=e.map(function(e){return e.byteLength}).reduce(function(e,t){return e+t}),n=new Uint8Array(t),r=0,o=0,i=e;o0&&o[o.length-1])&&(6===i[0]||2===i[0])){a=0;continue}if(3===i[0]&&(!o||i[1]>o[0]&&i[1]-1&&this.subject.observers.splice(e,1),0===this.subject.observers.length&&this.subject.cancelCallback&&this.subject.cancelCallback().catch(function(e){})},e}(),d=function(){function e(e){this.minimumLogLevel=e,this.outputConsole=console}return e.prototype.log=function(e,t){if(e>=this.minimumLogLevel)switch(e){case r.a.Critical:case r.a.Error:this.outputConsole.error("["+(new Date).toISOString()+"] "+r.a[e]+": "+t);break;case r.a.Warning:this.outputConsole.warn("["+(new Date).toISOString()+"] "+r.a[e]+": "+t);break;case r.a.Information:this.outputConsole.info("["+(new Date).toISOString()+"] "+r.a[e]+": "+t);break;default:this.outputConsole.log("["+(new Date).toISOString()+"] "+r.a[e]+": "+t)}},e}()},function(e,t,n){"use strict";n.r(t);var r,o,i=n(3),a=n(4),s=n(44),c=n(0),u=(r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var n in t)t.hasOwnProperty(n)&&(e[n]=t[n])},function(e,t){function n(){this.constructor=e}r(e,t),e.prototype=null===t?Object.create(t):(n.prototype=t.prototype,new n)}),l=function(e){function t(t){var n=e.call(this)||this;return n.logger=t,n}return u(t,e),t.prototype.send=function(e){var t=this;return e.abortSignal&&e.abortSignal.aborted?Promise.reject(new i.a):e.method?e.url?new Promise(function(n,r){var o=new XMLHttpRequest;o.open(e.method,e.url,!0),o.withCredentials=!0,o.setRequestHeader("X-Requested-With","XMLHttpRequest"),o.setRequestHeader("Content-Type","text/plain;charset=UTF-8");var s=e.headers;s&&Object.keys(s).forEach(function(e){o.setRequestHeader(e,s[e])}),e.responseType&&(o.responseType=e.responseType),e.abortSignal&&(e.abortSignal.onabort=function(){o.abort(),r(new i.a)}),e.timeout&&(o.timeout=e.timeout),o.onload=function(){e.abortSignal&&(e.abortSignal.onabort=null),o.status>=200&&o.status<300?n(new a.b(o.status,o.statusText,o.response||o.responseText)):r(new i.b(o.statusText,o.status))},o.onerror=function(){t.logger.log(c.a.Warning,"Error from HTTP request. "+o.status+": "+o.statusText+"."),r(new i.b(o.statusText,o.status))},o.ontimeout=function(){t.logger.log(c.a.Warning,"Timeout from HTTP request."),r(new i.c)},o.send(e.content||"")}):Promise.reject(new Error("No url defined.")):Promise.reject(new Error("No method defined."))},t}(a.a),f=function(){var e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var n in t)t.hasOwnProperty(n)&&(e[n]=t[n])};return function(t,n){function r(){this.constructor=t}e(t,n),t.prototype=null===n?Object.create(n):(r.prototype=n.prototype,new r)}}(),h=function(e){function t(t){var n=e.call(this)||this;return"undefined"!=typeof XMLHttpRequest?n.httpClient=new l(t):n.httpClient=new s.a(t),n}return f(t,e),t.prototype.send=function(e){return e.abortSignal&&e.abortSignal.aborted?Promise.reject(new i.a):e.method?e.url?this.httpClient.send(e):Promise.reject(new Error("No url defined.")):Promise.reject(new Error("No method defined."))},t.prototype.getCookieString=function(e){return this.httpClient.getCookieString(e)},t}(a.a),p=n(45);!function(e){e[e.Invocation=1]="Invocation",e[e.StreamItem=2]="StreamItem",e[e.Completion=3]="Completion",e[e.StreamInvocation=4]="StreamInvocation",e[e.CancelInvocation=5]="CancelInvocation",e[e.Ping=6]="Ping",e[e.Close=7]="Close"}(o||(o={}));var d,g=n(1),y=function(){function e(){this.observers=[]}return e.prototype.next=function(e){for(var t=0,n=this.observers;t0&&o[o.length-1])&&(6===i[0]||2===i[0])){a=0;continue}if(3===i[0]&&(!o||i[1]>o[0]&&i[1]0&&o[o.length-1])&&(6===i[0]||2===i[0])){a=0;continue}if(3===i[0]&&(!o||i[1]>o[0]&&i[1]0&&o[o.length-1])&&(6===i[0]||2===i[0])){a=0;continue}if(3===i[0]&&(!o||i[1]>o[0]&&i[1]0&&o[o.length-1])&&(6===i[0]||2===i[0])){a=0;continue}if(3===i[0]&&(!o||i[1]>o[0]&&i[1]0&&o[o.length-1])&&(6===i[0]||2===i[0])){a=0;continue}if(3===i[0]&&(!o||i[1]>o[0]&&i[1]0?[2,Promise.reject(new Error("Unable to connect to the server with any of the available transports. "+i.join(" ")))]:[2,Promise.reject(new Error("None of the transports supported by the client are supported by the server."))]}})})},e.prototype.constructTransport=function(e){switch(e){case E.WebSockets:if(!this.options.WebSocket)throw new Error("'WebSocket' is not supported in your environment.");return new A(this.httpClient,this.accessTokenFactory,this.logger,this.options.logMessageContent||!1,this.options.WebSocket);case E.ServerSentEvents:if(!this.options.EventSource)throw new Error("'EventSource' is not supported in your environment.");return new O(this.httpClient,this.accessTokenFactory,this.logger,this.options.logMessageContent||!1,this.options.EventSource);case E.LongPolling:return new P(this.httpClient,this.accessTokenFactory,this.logger,this.options.logMessageContent||!1);default:throw new Error("Unknown transport: "+e+".")}},e.prototype.startTransport=function(e,t){var n=this;return this.transport.onreceive=this.onreceive,this.transport.onclose=function(e){return n.stopConnection(e)},this.transport.connect(e,t)},e.prototype.resolveTransportOrError=function(e,t,n){var r=E[e.transport];if(null==r)return this.logger.log(c.a.Debug,"Skipping transport '"+e.transport+"' because it is not supported by this client."),new Error("Skipping transport '"+e.transport+"' because it is not supported by this client.");if(!function(e,t){return!e||0!=(t&e)}(t,r))return this.logger.log(c.a.Debug,"Skipping transport '"+E[r]+"' because it was disabled by the client."),new Error("'"+E[r]+"' is disabled by the client.");if(!(e.transferFormats.map(function(e){return S[e]}).indexOf(n)>=0))return this.logger.log(c.a.Debug,"Skipping transport '"+E[r]+"' because it does not support the requested transfer format '"+S[n]+"'."),new Error("'"+E[r]+"' does not support "+S[n]+".");if(r===E.WebSockets&&!this.options.WebSocket||r===E.ServerSentEvents&&!this.options.EventSource)return this.logger.log(c.a.Debug,"Skipping transport '"+E[r]+"' because it is not supported in your environment.'"),new Error("'"+E[r]+"' is not supported in your environment.");this.logger.log(c.a.Debug,"Selecting transport '"+E[r]+"'.");try{return this.constructTransport(r)}catch(e){return e}},e.prototype.isITransport=function(e){return e&&"object"==typeof e&&"connect"in e},e.prototype.stopConnection=function(e){if(this.logger.log(c.a.Debug,"HttpConnection.stopConnection("+e+") called while in state "+this.connectionState+"."),this.transport=void 0,e=this.stopError||e,this.stopError=void 0,"Disconnected"!==this.connectionState)if("Connecting "!==this.connectionState){if("Disconnecting"===this.connectionState&&this.stopPromiseResolver(),e?this.logger.log(c.a.Error,"Connection disconnected with error '"+e+"'."):this.logger.log(c.a.Information,"Connection disconnected."),this.connectionId=void 0,this.connectionState="Disconnected",this.onclose&&this.connectionStarted){this.connectionStarted=!1;try{this.onclose(e)}catch(t){this.logger.log(c.a.Error,"HttpConnection.onclose("+e+") threw error '"+t+"'.")}}}else this.logger.log(c.a.Warning,"Call to HttpConnection.stopConnection("+e+") was ignored because the connection hasn't yet left the in the connecting state.");else this.logger.log(c.a.Debug,"Call to HttpConnection.stopConnection("+e+") was ignored because the connection is already in the disconnected state.")},e.prototype.resolveUrl=function(e){if(0===e.lastIndexOf("https://",0)||0===e.lastIndexOf("http://",0))return e;if(!g.c.isBrowser||!window.document)throw new Error("Cannot resolve '"+e+"'.");var t=window.document.createElement("a");return t.href=e,this.logger.log(c.a.Information,"Normalizing '"+e+"' to '"+t.href+"'."),t.href},e.prototype.resolveNegotiateUrl=function(e){var t=e.indexOf("?"),n=e.substring(0,-1===t?e.length:t);return"/"!==n[n.length-1]&&(n+="/"),n+="negotiate",-1===(n+=-1===t?"":e.substring(t)).indexOf("negotiateVersion")&&(n+=-1===t?"?":"&",n+="negotiateVersion="+this.negotiateVersion),n},e}();var q=function(){function e(e){this.transport=e,this.buffer=[],this.executing=!0,this.sendBufferedData=new W,this.transportResult=new W,this.sendLoopPromise=this.sendLoop()}return e.prototype.send=function(e){return this.bufferData(e),this.transportResult||(this.transportResult=new W),this.transportResult.promise},e.prototype.stop=function(){return this.executing=!1,this.sendBufferedData.resolve(),this.sendLoopPromise},e.prototype.bufferData=function(e){if(this.buffer.length&&typeof this.buffer[0]!=typeof e)throw new Error("Expected data to be of type "+typeof this.buffer+" but was of type "+typeof e);this.buffer.push(e),this.sendBufferedData.resolve()},e.prototype.sendLoop=function(){return B(this,void 0,void 0,function(){var t,n,r;return j(this,function(o){switch(o.label){case 0:return[4,this.sendBufferedData.promise];case 1:if(o.sent(),!this.executing)return this.transportResult&&this.transportResult.reject("Connection stopped."),[3,6];this.sendBufferedData=new W,t=this.transportResult,this.transportResult=void 0,n="string"==typeof this.buffer[0]?this.buffer.join(""):e.concatBuffers(this.buffer),this.buffer.length=0,o.label=2;case 2:return o.trys.push([2,4,,5]),[4,this.transport.send(n)];case 3:return o.sent(),t.resolve(),[3,5];case 4:return r=o.sent(),t.reject(r),[3,5];case 5:return[3,0];case 6:return[2]}})})},e.concatBuffers=function(e){for(var t=e.map(function(e){return e.byteLength}).reduce(function(e,t){return e+t}),n=new Uint8Array(t),r=0,o=0,i=e;o 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() { } diff --git a/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netstandard2.0.cs b/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netstandard2.0.cs index cb686083cd..534580160f 100644 --- a/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netstandard2.0.cs +++ b/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 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() { } diff --git a/src/Components/Web/src/Forms/ValidationSummary.cs b/src/Components/Web/src/Forms/ValidationSummary.cs index 8e151b1e63..270f787176 100644 --- a/src/Components/Web/src/Forms/ValidationSummary.cs +++ b/src/Components/Web/src/Forms/ValidationSummary.cs @@ -19,6 +19,12 @@ namespace Microsoft.AspNetCore.Components.Forms private EditContext _previousEditContext; private readonly EventHandler _validationStateChangedHandler; + /// + /// 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. + /// + [Parameter] public object Model { get; set; } + /// /// Gets or sets a collection of additional attributes that will be applied to the created ul element. /// @@ -57,22 +63,31 @@ namespace Microsoft.AspNetCore.Components.Forms { // As an optimization, only evaluate the messages enumerable once, and // only produce the enclosing
    if there's at least one message - var messagesEnumerator = CurrentEditContext.GetValidationMessages().GetEnumerator(); - if (messagesEnumerator.MoveNext()) + var validationMessages = Model is null ? + CurrentEditContext.GetValidationMessages() : + CurrentEditContext.GetValidationMessages(new FieldIdentifier(Model, string.Empty)); + + var first = true; + foreach (var error in validationMessages) { - builder.OpenElement(0, "ul"); - builder.AddMultipleAttributes(1, AdditionalAttributes); - builder.AddAttribute(2, "class", "validation-errors"); - - do + if (first) { - builder.OpenElement(3, "li"); - builder.AddAttribute(4, "class", "validation-message"); - builder.AddContent(5, messagesEnumerator.Current); - builder.CloseElement(); - } - while (messagesEnumerator.MoveNext()); + first = false; + builder.OpenElement(0, "ul"); + builder.AddMultipleAttributes(1, AdditionalAttributes); + builder.AddAttribute(2, "class", "validation-errors"); + } + + 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(); } } diff --git a/src/Components/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs index 2d622ba864..edd7404f6f 100644 --- a/src/Components/test/E2ETest/Tests/FormsTest.cs +++ b/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(); + + protected virtual IWebElement MountTypicalValidationComponent() + => Browser.MountTestComponent(); + [Fact] public async Task EditFormWorksWithDataAnnotationsValidator() { - var appElement = Browser.MountTestComponent(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); 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(); + 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("a@b.com\t"); + + submitButton.Click(); + Browser.Empty(confirmEmailValidationMessage); + Browser.Equal(new[] { "Email and confirm email do not match." }, modelErrors); + + confirmInput.SendKeys("not-test@example.com\t"); + Browser.Equal(new[] { "Email and confirm email do not match." }, confirmEmailValidationMessage); + + // Can become correct + confirmInput.Clear(); + confirmInput.SendKeys("a@b.com\t"); + + Browser.Empty(confirmEmailValidationMessage); + + submitButton.Click(); + Browser.Empty(modelErrors); + } + [Fact] public void InputComponentsCauseContainerToRerenderOnChange() { - var appElement = Browser.MountTestComponent(); + 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); diff --git a/src/Components/test/E2ETest/Tests/FormsTestWithExperimentalValidator.cs b/src/Components/test/E2ETest/Tests/FormsTestWithExperimentalValidator.cs new file mode 100644 index 0000000000..dc2fb06b33 --- /dev/null +++ b/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 serverFixture, + ITestOutputHelper output) : base(browserFixture, serverFixture, output) + { + } + + protected override IWebElement MountSimpleValidationComponent() + => Browser.MountTestComponent(); + + protected override IWebElement MountTypicalValidationComponent() + => Browser.MountTestComponent(); + + [Fact] + public void EditFormWorksWithNestedValidation() + { + var appElement = Browser.MountTestComponent(); + + 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("test@example.com"); + 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("test@example.com"); + 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); + } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj b/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj index ac0e530516..98357d0e88 100644 --- a/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj +++ b/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj @@ -17,6 +17,7 @@ + diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/ExperimentalValidationComponent.razor b/src/Components/test/testassets/BasicTestApp/FormsTest/ExperimentalValidationComponent.razor new file mode 100644 index 0000000000..fe3df5382e --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/ExperimentalValidationComponent.razor @@ -0,0 +1,185 @@ +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms + +

    + This component is used to verify the use of the experimental ObjectGraphDataAnnotationsValidator type with IValidatableObject and deep validation, as well + as the ComparePropertyAttribute. +

    + + + + +

    + Name: + +

    + + + + + +
    + Items to deliver +

    + +

    +
      + @foreach (var item in model.Items) + { +
    • +
      +
      + + +
      + +
      + + +
      +
      + +
      +
      +
    • + } +
    +
    + +
    + Shipping details +

    + Street Address: + +

    +

    + Zip Code: + +

    +

    + Country: + + + + + + + + +

    +

    + +

    +
    + +
    + +
    + + +
    + +
    + +
    + +
      + @foreach (var entry in submissionLog) + { +
    • @entry
    • + } +
    + +@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 Items { get; } = new List + { + new Item(), + }; + + public IEnumerable 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 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 submissionLog = new List(); + + void HandleValidSubmit() + { + submissionLog.Add("OnValidSubmit"); + } + + void AddItem() + { + model.Items.Add(new Item()); + } +} diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.razor b/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.razor index aa8ccfbe54..2c9a4d2456 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponent.razor @@ -2,7 +2,14 @@ @using Microsoft.AspNetCore.Components.Forms - + @if (UseExperimentalValidator) + { + + } + else + { + + }

    User name: @@ -29,6 +36,8 @@ } @code { + protected virtual bool UseExperimentalValidator => false; + string lastCallback; [Required(ErrorMessage = "Please choose a username")] diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponentUsingExperimentalValidator.cs b/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponentUsingExperimentalValidator.cs new file mode 100644 index 0000000000..90484aad05 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/SimpleValidationComponentUsingExperimentalValidator.cs @@ -0,0 +1,7 @@ +namespace BasicTestApp.FormsTest +{ + public class TypicalValidationComponentUsingExperimentalValidator : TypicalValidationComponent + { + protected override bool UseExperimentalValidator => true; + } +} diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor index 1bd3b748d1..90b2f6a26b 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor @@ -2,7 +2,14 @@ @using Microsoft.AspNetCore.Components.Forms + @if (UseExperimentalValidator) + { + + } + else + { + }

    Name: @@ -11,6 +18,10 @@ Email:

    +

    Age (years):

    @@ -49,12 +60,18 @@ +

    + +

    +
      @foreach (var entry in submissionLog) {
    • @entry
    • }
    @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; } diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponentUsingExperimentalValidator.cs b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponentUsingExperimentalValidator.cs new file mode 100644 index 0000000000..9ede005a8a --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponentUsingExperimentalValidator.cs @@ -0,0 +1,7 @@ +namespace BasicTestApp.FormsTest +{ + public class SimpleValidationComponentUsingExperimentalValidator : SimpleValidationComponent + { + protected override bool UseExperimentalValidator => true; + } +} diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index fe94ad595e..f836fa6c39 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -29,7 +29,10 @@ + + + diff --git a/src/Shared/E2ETesting/BrowserAssertFailedException.cs b/src/Shared/E2ETesting/BrowserAssertFailedException.cs index 007db0ac79..08d04a912d 100644 --- a/src/Shared/E2ETesting/BrowserAssertFailedException.cs +++ b/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 logs, Exception innerException, string screenShotPath) - : base(BuildMessage(innerException, logs, screenShotPath), innerException) + public BrowserAssertFailedException(IReadOnlyList logs, Exception innerException, string screenShotPath, string innerHTML) + : base(BuildMessage(innerException, logs, screenShotPath, innerHTML), innerException) { } - private static string BuildMessage(Exception innerException, IReadOnlyList 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 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(); + } } } diff --git a/src/Shared/E2ETesting/WaitAssert.cs b/src/Shared/E2ETesting/WaitAssert.cs index 4ef1446191..8792d2692f 100644 --- a/src/Shared/E2ETesting/WaitAssert.cs +++ b/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;