Skip to content

Commit

Permalink
Validation fixes for Blazor
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
pranavkm committed Oct 11, 2019
1 parent 1141654 commit 9365a69
Show file tree
Hide file tree
Showing 23 changed files with 1,099 additions and 39 deletions.
1 change: 1 addition & 0 deletions eng/ProjectReferences.props
Expand Up @@ -137,6 +137,7 @@
<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" />
<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" />
<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" />
<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" />
<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" />
<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" />
<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" />
Expand Down
33 changes: 33 additions & 0 deletions src/Components/Blazor/Validation/src/BlazorCompareAttribute.cs
@@ -0,0 +1,33 @@
// 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>
public sealed class BlazorCompareAttribute : CompareAttribute
{
/// <summary>
/// Initializes a new instance of <see cref="BlazorCompareAttribute"/>.
/// </summary>
/// <param name="otherProperty">The property to compare with the current property.</param>
public BlazorCompareAttribute(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 });
}
}
}

116 changes: 116 additions & 0 deletions src/Components/Blazor/Validation/src/BlazorDataAnnotationsValidator.cs
@@ -0,0 +1,116 @@
// 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 BlazorDataAnnotationsValidator : ComponentBase
{
private static readonly object ValidationContextValidatorKey = 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);
EditContext.NotifyValidationStateChanged();
};

// Perform per-field validation on each field edit
EditContext.OnFieldChanged += (sender, eventArgs) =>
ValidateField(EditContext, _validationMessageStore, eventArgs.FieldIdentifier);
}

internal void ValidateObject(object value)
{
if (value is null)
{
return;
}

if (value is IEnumerable<object> enumerable)
{
var index = 0;
foreach (var item in enumerable)
{
ValidateObject(item);
index++;
}

return;
}

var validationResults = new List<ValidationResult>();
ValidateObject(value, 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, List<ValidationResult> validationResults)
{
var validationContext = new ValidationContext(value);
validationContext.Items.Add(ValidationContextValidatorKey, this);
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 BlazorDataAnnotationsValidator validator)
{
validator.ValidateObject(value);

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();
}
}
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<Description>Provides experimental support for validation of complex properties using DataAnnotations.</Description>
<IsShippingPackage>true</IsShippingPackage>
</PropertyGroup>

<ItemGroup>
<Reference Include="Microsoft.AspNetCore.Components.Forms" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.Tests" />
</ItemGroup>

</Project>
@@ -0,0 +1,29 @@
// 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="BlazorDataAnnotationsValidator"/>,
/// 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 (!BlazorDataAnnotationsValidator.TryValidateRecursive(value, validationContext))
{
throw new InvalidOperationException($"{nameof(ValidateComplexTypeAttribute)} can only used with {nameof(BlazorDataAnnotationsValidator)}.");
}

return ValidationResult.Success;
}
}
}

1 comment on commit 9365a69

@m0ddixx
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this already inlcuded in a pull request?

Please sign in to comment.