Skip to content

Components: Forms and validation #7614

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 70 commits into from
Feb 20, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
7d13f52
Add FieldIdentifier
SteveSandersonMS Feb 12, 2019
2a06bb1
Beginning EditContext
SteveSandersonMS Feb 12, 2019
ef07f6e
Tracks modifications and notifies about field value changes
SteveSandersonMS Feb 12, 2019
ea75b9f
Improve XML doc
SteveSandersonMS Feb 12, 2019
a06bb3a
Extend tests to show fields don't have to be on the EditContext model
SteveSandersonMS Feb 12, 2019
834dd3f
Begin ValidationMessageStore
SteveSandersonMS Feb 12, 2019
2705a37
Efficiently list validation messages across stores
SteveSandersonMS Feb 12, 2019
9a1ea50
Move unit tests to better place
SteveSandersonMS Feb 12, 2019
28b7c41
Add notes about async validation plan
SteveSandersonMS Feb 13, 2019
4b14bb9
Very basic synchronous Validate method
SteveSandersonMS Feb 13, 2019
65de11a
Add DataAnnotations validation support
SteveSandersonMS Feb 13, 2019
945e2f0
Add EditForm and DataAnnotationsValidator component
SteveSandersonMS Feb 13, 2019
937e617
Begin TypicalValidationComponent for E2E tests
SteveSandersonMS Feb 13, 2019
a06b059
Implement ValidationSummary and notifications of validation state change
SteveSandersonMS Feb 13, 2019
cd3547a
Support per-field validation for DataAnnotations
SteveSandersonMS Feb 14, 2019
f0a2650
Support for converting Expression<Func<object>> to FieldIdentifier
SteveSandersonMS Feb 14, 2019
9547035
Extension methods for convenience when using accessors
SteveSandersonMS Feb 14, 2019
dffd2ee
Support conversion to FieldIdentifier from any Expression<Func<T>>
SteveSandersonMS Feb 14, 2019
b0f184c
Beginning on InputBase
SteveSandersonMS Feb 14, 2019
ebe43a6
More InputBase
SteveSandersonMS Feb 14, 2019
241af1c
Don't use OnInit
SteveSandersonMS Feb 14, 2019
3dc0cb8
Add XML docs
SteveSandersonMS Feb 14, 2019
0cf1637
Move InputBase logic into SetParameters to avoid making lifecycle met…
SteveSandersonMS Feb 14, 2019
78cf6e2
Simplify tests
SteveSandersonMS Feb 14, 2019
5bd0622
Add CssClass to InputBase
SteveSandersonMS Feb 14, 2019
aa03e79
Add CurrentValueAsString with virtual formatting/parsing methods
SteveSandersonMS Feb 14, 2019
4c12f51
Add InputText, InputNumber
SteveSandersonMS Feb 15, 2019
9a29045
Add InputCheckbox
SteveSandersonMS Feb 15, 2019
4ed7793
Tweak message for consistency with Data Annotations defaults
SteveSandersonMS Feb 15, 2019
12a1a45
Add InputTextArea
SteveSandersonMS Feb 15, 2019
16b468c
Add notes
SteveSandersonMS Feb 15, 2019
91ee810
Add InputDate
SteveSandersonMS Feb 15, 2019
64865a2
Add InputSelect
SteveSandersonMS Feb 15, 2019
0842d01
Add note
SteveSandersonMS Feb 15, 2019
1de4043
Support nullable input types
SteveSandersonMS Feb 15, 2019
98ca69b
Add EditContextFieldClassExtensions, so we can use FieldClass from ou…
SteveSandersonMS Feb 15, 2019
f22e807
Add example of integrating with INotifyPropertyChanged
SteveSandersonMS Feb 15, 2019
5eaf3cd
Don't leak
SteveSandersonMS Feb 15, 2019
ddfa682
Clean up
SteveSandersonMS Feb 15, 2019
965617e
Add 'id' support to Input*
SteveSandersonMS Feb 18, 2019
c88fce8
Add 'class' support to Input*
SteveSandersonMS Feb 18, 2019
c309cb7
Update references following rebase
SteveSandersonMS Feb 18, 2019
28f7757
CR feedback: Number parsing
SteveSandersonMS Feb 18, 2019
f207979
CR: In InputNumber, add "type='number'" and conditional "step='any'"
SteveSandersonMS Feb 18, 2019
741c0ad
CR: Remove async validation comments
SteveSandersonMS Feb 18, 2019
f4b77c6
CR: sealed EditContext
SteveSandersonMS Feb 18, 2019
c35639f
CR: Add eventargs classes
SteveSandersonMS Feb 18, 2019
2d760f2
CR: Comment about EditContext storage
SteveSandersonMS Feb 18, 2019
59be3b6
Use EventCallback in EditForm
SteveSandersonMS Feb 18, 2019
57d4279
Eliminate events routing bug workarounds since the underlying issue i…
SteveSandersonMS Feb 18, 2019
35e36b0
CR: Avoid some System.Linq allocations
SteveSandersonMS Feb 18, 2019
a270131
CR: Null check accessor
SteveSandersonMS Feb 18, 2019
4b52cb3
CR: Use 'in' with FieldIdentifier
SteveSandersonMS Feb 18, 2019
fa8e377
CR: Make TryParseValueFromString abstract
SteveSandersonMS Feb 18, 2019
f39239b
CR: Seal ValidationMessageStore
SteveSandersonMS Feb 18, 2019
a072653
CR: Avoid use of .Values
SteveSandersonMS Feb 18, 2019
39ab4dc
Fix Blazor test
SteveSandersonMS Feb 18, 2019
283a7dc
CR: In tests, don't make assumpions about DataAnnotations messages
SteveSandersonMS Feb 18, 2019
4fb9486
CR: Clean up test namespaces
SteveSandersonMS Feb 18, 2019
87a7161
CR: Reorder usings
SteveSandersonMS Feb 18, 2019
5573ebe
Update build config
SteveSandersonMS Feb 18, 2019
454ee02
E2E test for simple validation scenario
SteveSandersonMS Feb 18, 2019
1c68569
E2E tests for built-in Input* components
SteveSandersonMS Feb 18, 2019
1b5f965
E2E test for INotifyPropertyChanged integration scenario
SteveSandersonMS Feb 18, 2019
d63f817
Add ValidationMessage component
SteveSandersonMS Feb 18, 2019
2658d7e
E2E test for ValidationMessage
SteveSandersonMS Feb 19, 2019
255d0e8
Update build config
SteveSandersonMS Feb 19, 2019
3cc40e4
Versioning changes as requested
SteveSandersonMS Feb 19, 2019
4f7bdc0
FieldIdentifier enhancements as discussed
SteveSandersonMS Feb 19, 2019
dd0929b
Empty commit to trigger CI
SteveSandersonMS Feb 19, 2019
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions eng/Dependencies.props
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ and are generated based on the last package release.
<LatestPackageReference Include="StackExchange.Redis" Version="$(StackExchangeRedisPackageVersion)" />
<LatestPackageReference Include="System.Buffers" Version="$(SystemBuffersPackageVersion)" />
<LatestPackageReference Include="System.CodeDom" Version="$(SystemCodeDomPackageVersion)" />
<LatestPackageReference Include="System.ComponentModel" Version="$(SystemComponentModelPackageVersion)" />
<LatestPackageReference Include="System.ComponentModel.Annotations" Version="$(SystemComponentModelAnnotationsPackageVersion)" />
<LatestPackageReference Include="System.Data.SqlClient" Version="$(SystemDataSqlClientPackageVersion)" />
<LatestPackageReference Include="System.Diagnostics.EventLog" Version="$(SystemDiagnosticsEventLogPackageVersion)" />
<LatestPackageReference Include="System.IdentityModel.Tokens.Jwt" Version="$(SystemIdentityModelTokensJwtPackageVersion)" />
Expand Down
1 change: 1 addition & 0 deletions eng/SharedFramework.External.props
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
-->
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<_CompilationOnlyReference Include="System.Buffers" />
<_CompilationOnlyReference Include="System.ComponentModel.Annotations" />
</ItemGroup>

<!--
Expand Down
4 changes: 4 additions & 0 deletions eng/Version.Details.xml
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,10 @@
<Uri>https://github.com/dotnet/corefx</Uri>
<Sha>0abec4390b30fdda97dc496594f9b1f9c9b20e17</Sha>
</Dependency>
<Dependency Name="System.ComponentModel.Annotations" Version="4.6.0-preview.19109.6">
<Uri>https://github.com/dotnet/corefx</Uri>
<Sha>0abec4390b30fdda97dc496594f9b1f9c9b20e17</Sha>
</Dependency>
<Dependency Name="System.Data.SqlClient" Version="4.7.0-preview.19109.6">
<Uri>https://github.com/dotnet/corefx</Uri>
<Sha>0abec4390b30fdda97dc496594f9b1f9c9b20e17</Sha>
Expand Down
2 changes: 2 additions & 0 deletions eng/Versions.props
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
<MicrosoftBclJsonSourcesPackageVersion>4.6.0-preview.19109.6</MicrosoftBclJsonSourcesPackageVersion>
<MicrosoftCSharpPackageVersion>4.6.0-preview.19109.6</MicrosoftCSharpPackageVersion>
<MicrosoftWin32RegistryPackageVersion>4.6.0-preview.19109.6</MicrosoftWin32RegistryPackageVersion>
<SystemComponentModelAnnotationsPackageVersion>4.6.0-preview.19109.6</SystemComponentModelAnnotationsPackageVersion>
<SystemDataSqlClientPackageVersion>4.7.0-preview.19109.6</SystemDataSqlClientPackageVersion>
<SystemDiagnosticsEventLogPackageVersion>4.6.0-preview.19109.6</SystemDiagnosticsEventLogPackageVersion>
<SystemIOPipelinesPackageVersion>4.6.0-preview.19109.6</SystemIOPipelinesPackageVersion>
Expand Down Expand Up @@ -134,6 +135,7 @@
<!-- Stable dotnet/corefx packages no longer updated for .NET Core 3 -->
<SystemBuffersPackageVersion>4.5.0</SystemBuffersPackageVersion>
<SystemCodeDomPackageVersion>4.4.0</SystemCodeDomPackageVersion>
<SystemComponentModelPackageVersion>4.3.0</SystemComponentModelPackageVersion>
<SystemNetHttpPackageVersion>4.3.2</SystemNetHttpPackageVersion>
<SystemThreadingTasksExtensionsPackageVersion>4.5.2</SystemThreadingTasksExtensionsPackageVersion>
<!-- Packages developed by @aspnet, but manually updated as necessary. -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,9 @@
to implement timers. Fixes https://github.com/aspnet/Blazor/issues/239 -->
<type fullname="System.Threading.WasmRuntime" />
</assembly>

<assembly fullname="System">
<!-- Without this, [Required(typeof(bool), "true", "true", ErrorMessage = "...")] fails -->
<type fullname="System.ComponentModel.BooleanConverter" />
</assembly>
</linker>
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ uncalled implementation code from mscorlib.dll anyway.
"System.Collections.dll",
"System.ComponentModel.Composition.dll",
"System.ComponentModel.dll",
"System.ComponentModel.Annotations.dll",
"System.ComponentModel.DataAnnotations.dll",
"System.Core.dll",
"System.Data.dll",
"System.Diagnostics.Debug.dll",
Expand Down
28 changes: 28 additions & 0 deletions src/Components/Components/src/Forms/DataAnnotationsValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// 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;

namespace Microsoft.AspNetCore.Components.Forms
{
/// <summary>
/// Adds Data Annotations validation support to an <see cref="EditContext"/>.
/// </summary>
public class DataAnnotationsValidator : ComponentBase
{
[CascadingParameter] EditContext CurrentEditContext { get; set; }

/// <inheritdoc />
protected override void OnInit()
{
if (CurrentEditContext == null)
{
throw new InvalidOperationException($"{nameof(DataAnnotationsValidator)} requires a cascading " +
$"parameter of type {nameof(EditContext)}. For example, you can use {nameof(DataAnnotationsValidator)} " +
$"inside an {nameof(EditForm)}.");
}

CurrentEditContext.AddDataAnnotationsValidation();
}
}
}
191 changes: 191 additions & 0 deletions src/Components/Components/src/Forms/EditContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// 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.Linq;

namespace Microsoft.AspNetCore.Components.Forms
{
/// <summary>
/// Holds metadata related to a data editing process, such as flags to indicate which
/// fields have been modified and the current set of validation messages.
/// </summary>
public sealed class EditContext
{
// Note that EditContext tracks state for any FieldIdentifier you give to it, plus
// the underlying storage is sparse. As such, none of the APIs have a "field not found"
// error state. If you give us an unrecognized FieldIdentifier, that just means we
// didn't yet track any state for it, so we behave as if it's in the default state
// (valid and unmodified).
private readonly Dictionary<FieldIdentifier, FieldState> _fieldStates = new Dictionary<FieldIdentifier, FieldState>();

/// <summary>
/// Constructs an instance of <see cref="EditContext"/>.
/// </summary>
/// <param name="model">The model object for the <see cref="EditContext"/>. This object should hold the data being edited, for example as a set of properties.</param>
public EditContext(object model)
{
// The only reason we disallow null is because you'd almost always want one, and if you
// really don't, you can pass an empty object then ignore it. Ensuring it's nonnull
// simplifies things for all consumers of EditContext.
Model = model ?? throw new ArgumentNullException(nameof(model));
}

/// <summary>
/// An event that is raised when a field value changes.
/// </summary>
public event EventHandler<FieldChangedEventArgs> OnFieldChanged;

/// <summary>
/// An event that is raised when validation is requested.
/// </summary>
public event EventHandler<ValidationRequestedEventArgs> OnValidationRequested;

/// <summary>
/// An event that is raised when validation state has changed.
/// </summary>
public event EventHandler<ValidationStateChangedEventArgs> OnValidationStateChanged;

/// <summary>
/// Supplies a <see cref="FieldIdentifier"/> corresponding to a specified field name
/// on this <see cref="EditContext"/>'s <see cref="Model"/>.
/// </summary>
/// <param name="fieldName">The name of the editable field.</param>
/// <returns>A <see cref="FieldIdentifier"/> corresponding to a specified field name on this <see cref="EditContext"/>'s <see cref="Model"/>.</returns>
public FieldIdentifier Field(string fieldName)
=> new FieldIdentifier(Model, fieldName);

/// <summary>
/// Gets the model object for this <see cref="EditContext"/>.
/// </summary>
public object Model { get; }

/// <summary>
/// Signals that the value for the specified field has changed.
/// </summary>
/// <param name="fieldIdentifier">Identifies the field whose value has been changed.</param>
public void NotifyFieldChanged(in FieldIdentifier fieldIdentifier)
{
GetFieldState(fieldIdentifier, ensureExists: true).IsModified = true;
OnFieldChanged?.Invoke(this, new FieldChangedEventArgs(fieldIdentifier));
}

/// <summary>
/// Signals that some aspect of validation state has changed.
/// </summary>
public void NotifyValidationStateChanged()
{
OnValidationStateChanged?.Invoke(this, ValidationStateChangedEventArgs.Empty);
}

/// <summary>
/// Clears any modification flag that may be tracked for the specified field.
/// </summary>
/// <param name="fieldIdentifier">Identifies the field whose modification flag (if any) should be cleared.</param>
public void MarkAsUnmodified(in FieldIdentifier fieldIdentifier)
{
if (_fieldStates.TryGetValue(fieldIdentifier, out var state))
{
state.IsModified = false;
}
}

/// <summary>
/// Clears all modification flags within this <see cref="EditContext"/>.
/// </summary>
public void MarkAsUnmodified()
{
foreach (var state in _fieldStates)
{
state.Value.IsModified = false;
}
}

/// <summary>
/// Determines whether any of the fields in this <see cref="EditContext"/> have been modified.
/// </summary>
/// <returns>True if any of the fields in this <see cref="EditContext"/> have been modified; otherwise false.</returns>
public bool IsModified()
{
// If necessary, we could consider caching the overall "is modified" state and only recomputing
// when there's a call to NotifyFieldModified/NotifyFieldUnmodified
foreach (var state in _fieldStates)
{
if (state.Value.IsModified)
{
return true;
}
}

return false;
}

/// <summary>
/// Gets the current validation messages across all fields.
///
/// This method does not perform validation itself. It only returns messages determined by previous validation actions.
/// </summary>
/// <returns>The current validation messages.</returns>
public IEnumerable<string> GetValidationMessages()
{
// Since we're only enumerating the fields for which we have a non-null state, the cost of this grows
// based on how many fields have been modified or have associated validation messages
foreach (var state in _fieldStates)
{
foreach (var message in state.Value.GetValidationMessages())
{
yield return message;
}
}
}

/// <summary>
/// Gets the current validation messages for the specified field.
///
/// This method does not perform validation itself. It only returns messages determined by previous validation actions.
/// </summary>
/// <param name="fieldIdentifier">Identifies the field whose current validation messages should be returned.</param>
/// <returns>The current validation messages for the specified field.</returns>
public IEnumerable<string> GetValidationMessages(FieldIdentifier fieldIdentifier)
{
if (_fieldStates.TryGetValue(fieldIdentifier, out var state))
{
foreach (var message in state.GetValidationMessages())
{
yield return message;
}
}
}

/// <summary>
/// Determines whether the specified fields in this <see cref="EditContext"/> has been modified.
/// </summary>
/// <returns>True if the field has been modified; otherwise false.</returns>
public bool IsModified(in FieldIdentifier fieldIdentifier)
=> _fieldStates.TryGetValue(fieldIdentifier, out var state)
? state.IsModified
: false;

/// <summary>
/// Validates this <see cref="EditContext"/>.
/// </summary>
/// <returns>True if there are no validation messages after validation; otherwise false.</returns>
public bool Validate()
{
OnValidationRequested?.Invoke(this, ValidationRequestedEventArgs.Empty);
return !GetValidationMessages().Any();
}

internal FieldState GetFieldState(in FieldIdentifier fieldIdentifier, bool ensureExists)
{
if (!_fieldStates.TryGetValue(fieldIdentifier, out var state) && ensureExists)
{
state = new FieldState(fieldIdentifier);
_fieldStates.Add(fieldIdentifier, state);
}

return state;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// 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.Concurrent;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Reflection;

namespace Microsoft.AspNetCore.Components.Forms
{
/// <summary>
/// Extension methods to add DataAnnotations validation to an <see cref="EditContext"/>.
/// </summary>
public static class EditContextDataAnnotationsExtensions
{
private static ConcurrentDictionary<(Type ModelType, string FieldName), PropertyInfo> _propertyInfoCache
= new ConcurrentDictionary<(Type, string), PropertyInfo>();

/// <summary>
/// Adds DataAnnotations validation support to the <see cref="EditContext"/>.
/// </summary>
/// <param name="editContext">The <see cref="EditContext"/>.</param>
public static EditContext AddDataAnnotationsValidation(this EditContext editContext)
{
if (editContext == null)
{
throw new ArgumentNullException(nameof(editContext));
}

var messages = new ValidationMessageStore(editContext);

// Perform object-level validation on request
editContext.OnValidationRequested +=
(sender, eventArgs) => ValidateModel((EditContext)sender, messages);

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

return editContext;
}

private static void ValidateModel(EditContext editContext, ValidationMessageStore messages)
{
var validationContext = new ValidationContext(editContext.Model);
var validationResults = new List<ValidationResult>();
Validator.TryValidateObject(editContext.Model, validationContext, validationResults, true);

// Transfer results to the ValidationMessageStore
messages.Clear();
foreach (var validationResult in validationResults)
{
foreach (var memberName in validationResult.MemberNames)
{
messages.Add(editContext.Field(memberName), validationResult.ErrorMessage);
}
}

editContext.NotifyValidationStateChanged();
}

private static void ValidateField(EditContext editContext, ValidationMessageStore messages, in FieldIdentifier fieldIdentifier)
{
if (TryGetValidatableProperty(fieldIdentifier, out var propertyInfo))
{
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.AddRange(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();
}
}

private static bool TryGetValidatableProperty(in FieldIdentifier fieldIdentifier, out PropertyInfo propertyInfo)
{
var cacheKey = (ModelType: fieldIdentifier.Model.GetType(), fieldIdentifier.FieldName);
if (!_propertyInfoCache.TryGetValue(cacheKey, out propertyInfo))
{
// DataAnnotations only validates public properties, so that's all we'll look for
// If we can't find it, cache 'null' so we don't have to try again next time
propertyInfo = cacheKey.ModelType.GetProperty(cacheKey.FieldName);

// No need to lock, because it doesn't matter if we write the same value twice
_propertyInfoCache[cacheKey] = propertyInfo;
}

return propertyInfo != null;
}
}
}
Loading