Skip to content

Commit 0c3f759

Browse files
committed
Validation fixes for Blazor
* Ensure validation result that are not associated with a member are recorded. Fixes #10643 * Add support for showing model-specific errors to ValidationSummary * Add support for nested validation and a more suitable CompareAttribute. Fixes #10526
1 parent a4af618 commit 0c3f759

23 files changed

+1097
-37
lines changed

eng/ProjectReferences.props

+1
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@
137137
<ProjectReferenceProvider Include="Microsoft.AspNetCore.Blazor" ProjectPath="$(RepoRoot)src\Components\Blazor\Blazor\src\Microsoft.AspNetCore.Blazor.csproj" RefProjectPath="$(RepoRoot)src\Components\Blazor\Blazor\ref\Microsoft.AspNetCore.Blazor.csproj" />
138138
<ProjectReferenceProvider Include="Microsoft.AspNetCore.Blazor.HttpClient" ProjectPath="$(RepoRoot)src\Components\Blazor\Http\src\Microsoft.AspNetCore.Blazor.HttpClient.csproj" RefProjectPath="$(RepoRoot)src\Components\Blazor\Http\ref\Microsoft.AspNetCore.Blazor.HttpClient.csproj" />
139139
<ProjectReferenceProvider Include="Microsoft.AspNetCore.Blazor.Server" ProjectPath="$(RepoRoot)src\Components\Blazor\Server\src\Microsoft.AspNetCore.Blazor.Server.csproj" RefProjectPath="$(RepoRoot)src\Components\Blazor\Server\ref\Microsoft.AspNetCore.Blazor.Server.csproj" />
140+
<ProjectReferenceProvider Include="Microsoft.AspNetCore.Blazor.DataAnnotations.Validation" ProjectPath="$(RepoRoot)src\Components\Blazor\Validation\src\Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.csproj" RefProjectPath="$(RepoRoot)src\Components\Blazor\Validation\ref\Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.csproj" />
140141
<ProjectReferenceProvider Include="Microsoft.AspNetCore.Components" ProjectPath="$(RepoRoot)src\Components\Components\src\Microsoft.AspNetCore.Components.csproj" RefProjectPath="$(RepoRoot)src\Components\Components\ref\Microsoft.AspNetCore.Components.csproj" />
141142
<ProjectReferenceProvider Include="Microsoft.AspNetCore.Components.Forms" ProjectPath="$(RepoRoot)src\Components\Forms\src\Microsoft.AspNetCore.Components.Forms.csproj" RefProjectPath="$(RepoRoot)src\Components\Forms\ref\Microsoft.AspNetCore.Components.Forms.csproj" />
142143
<ProjectReferenceProvider Include="Microsoft.AspNetCore.Components.Server" ProjectPath="$(RepoRoot)src\Components\Server\src\Microsoft.AspNetCore.Components.Server.csproj" RefProjectPath="$(RepoRoot)src\Components\Server\ref\Microsoft.AspNetCore.Components.Server.csproj" />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
namespace System.ComponentModel.DataAnnotations
5+
{
6+
/// <summary>
7+
/// A <see cref="ValidationAttribute"/> that compares two properties
8+
/// </summary>
9+
public sealed class BlazorCompareAttribute : CompareAttribute
10+
{
11+
/// <summary>
12+
/// Initializes a new instance of <see cref="BlazorCompareAttribute"/>.
13+
/// </summary>
14+
/// <param name="otherProperty">The property to compare with the current property.</param>
15+
public BlazorCompareAttribute(string otherProperty)
16+
: base(otherProperty)
17+
{
18+
}
19+
20+
/// <inheritdoc />
21+
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
22+
{
23+
var validationResult = base.IsValid(value, validationContext);
24+
if (validationResult == ValidationResult.Success)
25+
{
26+
return validationResult;
27+
}
28+
29+
return new ValidationResult(validationResult.ErrorMessage, new[] { validationContext.MemberName });
30+
}
31+
}
32+
}
33+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System.Collections.Generic;
5+
using System.ComponentModel.DataAnnotations;
6+
using System.Linq;
7+
8+
namespace Microsoft.AspNetCore.Components.Forms
9+
{
10+
public class BlazorDataAnnotationsValidator : ComponentBase
11+
{
12+
private static readonly object ValidationContextValidatorKey = new object();
13+
private ValidationMessageStore _validationMessageStore;
14+
15+
[CascadingParameter]
16+
internal EditContext EditContext { get; set; }
17+
18+
protected override void OnInitialized()
19+
{
20+
_validationMessageStore = new ValidationMessageStore(EditContext);
21+
22+
// Perform object-level validation (starting from the root model) on request
23+
EditContext.OnValidationRequested += (sender, eventArgs) =>
24+
{
25+
_validationMessageStore.Clear();
26+
ValidateObject(EditContext.Model);
27+
EditContext.NotifyValidationStateChanged();
28+
};
29+
30+
// Perform per-field validation on each field edit
31+
EditContext.OnFieldChanged += (sender, eventArgs) =>
32+
ValidateField(EditContext, _validationMessageStore, eventArgs.FieldIdentifier);
33+
}
34+
35+
internal void ValidateObject(object value)
36+
{
37+
if (value is null)
38+
{
39+
return;
40+
}
41+
42+
if (value is IEnumerable<object> enumerable)
43+
{
44+
var index = 0;
45+
foreach (var item in enumerable)
46+
{
47+
ValidateObject(item);
48+
index++;
49+
}
50+
51+
return;
52+
}
53+
54+
var validationResults = new List<ValidationResult>();
55+
ValidateObject(value, validationResults);
56+
57+
// Transfer results to the ValidationMessageStore
58+
foreach (var validationResult in validationResults)
59+
{
60+
if (!validationResult.MemberNames.Any())
61+
{
62+
_validationMessageStore.Add(new FieldIdentifier(value, string.Empty), validationResult.ErrorMessage);
63+
continue;
64+
}
65+
66+
foreach (var memberName in validationResult.MemberNames)
67+
{
68+
var fieldIdentifier = new FieldIdentifier(value, memberName);
69+
_validationMessageStore.Add(fieldIdentifier, validationResult.ErrorMessage);
70+
}
71+
}
72+
}
73+
74+
private void ValidateObject(object value, List<ValidationResult> validationResults)
75+
{
76+
var validationContext = new ValidationContext(value);
77+
validationContext.Items.Add(ValidationContextValidatorKey, this);
78+
Validator.TryValidateObject(value, validationContext, validationResults, validateAllProperties: true);
79+
}
80+
81+
internal static bool TryValidateRecursive(object value, ValidationContext validationContext)
82+
{
83+
if (validationContext.Items.TryGetValue(ValidationContextValidatorKey, out var result) && result is BlazorDataAnnotationsValidator validator)
84+
{
85+
validator.ValidateObject(value);
86+
87+
return true;
88+
}
89+
90+
return false;
91+
}
92+
93+
private static void ValidateField(EditContext editContext, ValidationMessageStore messages, in FieldIdentifier fieldIdentifier)
94+
{
95+
// DataAnnotations only validates public properties, so that's all we'll look for
96+
var propertyInfo = fieldIdentifier.Model.GetType().GetProperty(fieldIdentifier.FieldName);
97+
if (propertyInfo != null)
98+
{
99+
var propertyValue = propertyInfo.GetValue(fieldIdentifier.Model);
100+
var validationContext = new ValidationContext(fieldIdentifier.Model)
101+
{
102+
MemberName = propertyInfo.Name
103+
};
104+
var results = new List<ValidationResult>();
105+
106+
Validator.TryValidateProperty(propertyValue, validationContext, results);
107+
messages.Clear(fieldIdentifier);
108+
messages.Add(fieldIdentifier, results.Select(result => result.ErrorMessage));
109+
110+
// We have to notify even if there were no messages before and are still no messages now,
111+
// because the "state" that changed might be the completion of some async validation task
112+
editContext.NotifyValidationStateChanged();
113+
}
114+
}
115+
}
116+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>netstandard2.0</TargetFramework>
5+
<Description>Provides experimental support for validation of complex properties using DataAnnotations.</Description>
6+
<IsShippingPackage>true</IsShippingPackage>
7+
</PropertyGroup>
8+
9+
<ItemGroup>
10+
<Reference Include="Microsoft.AspNetCore.Components.Forms" />
11+
</ItemGroup>
12+
13+
<ItemGroup>
14+
<InternalsVisibleTo Include="Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.Tests" />
15+
</ItemGroup>
16+
17+
</Project>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using Microsoft.AspNetCore.Components.Forms;
5+
6+
namespace System.ComponentModel.DataAnnotations
7+
{
8+
/// <summary>
9+
/// A <see cref="ValidationAttribute"/> that indicates that the property is a complex or collection type that further needs to be validated.
10+
/// <para>
11+
/// By default <see cref="Validator"/> does not recurse in to complex property types during validation. When used in conjunction with <see cref="BlazorDataAnnotationsValidator"/>,
12+
/// this property allows the validation system to validate complex or collection type properties.
13+
/// </para>
14+
/// </summary>
15+
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
16+
public sealed class ValidateComplexTypeAttribute : ValidationAttribute
17+
{
18+
/// <inheritdoc />
19+
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
20+
{
21+
if (!BlazorDataAnnotationsValidator.TryValidateRecursive(value, validationContext))
22+
{
23+
throw new InvalidOperationException($"{nameof(ValidateComplexTypeAttribute)} can only used with {nameof(BlazorDataAnnotationsValidator)}.");
24+
}
25+
26+
return ValidationResult.Success;
27+
}
28+
}
29+
}

0 commit comments

Comments
 (0)