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
This commit is contained in:
Pranav K 2019-10-18 11:26:17 -07:00 committed by GitHub
parent 5440cd319d
commit c298c94fe1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1241 additions and 42 deletions

View File

@ -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" />

View File

@ -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 });
}
}
}

View File

@ -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>

View File

@ -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();
}
}
}
}

View File

@ -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;
}
}
}

View File

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

View File

@ -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();
}
}
}

View File

@ -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}

View File

@ -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",

View File

@ -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);

View File

@ -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 suppressed because one or more lines are too long

View File

@ -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() { }

View File

@ -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() { }

View File

@ -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())
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();
}
}

View File

@ -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("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<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);

View File

@ -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("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);
}
}
}

View File

@ -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>

View File

@ -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());
}
}

View File

@ -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")]

View File

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

View File

@ -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; }

View File

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

View File

@ -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>

View File

@ -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();
}
}
}

View File

@ -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;