-
Notifications
You must be signed in to change notification settings - Fork 10.4k
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
Changes from all commits
Commits
Show all changes
70 commits
Select commit
Hold shift + click to select a range
7d13f52
Add FieldIdentifier
SteveSandersonMS 2a06bb1
Beginning EditContext
SteveSandersonMS ef07f6e
Tracks modifications and notifies about field value changes
SteveSandersonMS ea75b9f
Improve XML doc
SteveSandersonMS a06bb3a
Extend tests to show fields don't have to be on the EditContext model
SteveSandersonMS 834dd3f
Begin ValidationMessageStore
SteveSandersonMS 2705a37
Efficiently list validation messages across stores
SteveSandersonMS 9a1ea50
Move unit tests to better place
SteveSandersonMS 28b7c41
Add notes about async validation plan
SteveSandersonMS 4b14bb9
Very basic synchronous Validate method
SteveSandersonMS 65de11a
Add DataAnnotations validation support
SteveSandersonMS 945e2f0
Add EditForm and DataAnnotationsValidator component
SteveSandersonMS 937e617
Begin TypicalValidationComponent for E2E tests
SteveSandersonMS a06b059
Implement ValidationSummary and notifications of validation state change
SteveSandersonMS cd3547a
Support per-field validation for DataAnnotations
SteveSandersonMS f0a2650
Support for converting Expression<Func<object>> to FieldIdentifier
SteveSandersonMS 9547035
Extension methods for convenience when using accessors
SteveSandersonMS dffd2ee
Support conversion to FieldIdentifier from any Expression<Func<T>>
SteveSandersonMS b0f184c
Beginning on InputBase
SteveSandersonMS ebe43a6
More InputBase
SteveSandersonMS 241af1c
Don't use OnInit
SteveSandersonMS 3dc0cb8
Add XML docs
SteveSandersonMS 0cf1637
Move InputBase logic into SetParameters to avoid making lifecycle met…
SteveSandersonMS 78cf6e2
Simplify tests
SteveSandersonMS 5bd0622
Add CssClass to InputBase
SteveSandersonMS aa03e79
Add CurrentValueAsString with virtual formatting/parsing methods
SteveSandersonMS 4c12f51
Add InputText, InputNumber
SteveSandersonMS 9a29045
Add InputCheckbox
SteveSandersonMS 4ed7793
Tweak message for consistency with Data Annotations defaults
SteveSandersonMS 12a1a45
Add InputTextArea
SteveSandersonMS 16b468c
Add notes
SteveSandersonMS 91ee810
Add InputDate
SteveSandersonMS 64865a2
Add InputSelect
SteveSandersonMS 0842d01
Add note
SteveSandersonMS 1de4043
Support nullable input types
SteveSandersonMS 98ca69b
Add EditContextFieldClassExtensions, so we can use FieldClass from ou…
SteveSandersonMS f22e807
Add example of integrating with INotifyPropertyChanged
SteveSandersonMS 5eaf3cd
Don't leak
SteveSandersonMS ddfa682
Clean up
SteveSandersonMS 965617e
Add 'id' support to Input*
SteveSandersonMS c88fce8
Add 'class' support to Input*
SteveSandersonMS c309cb7
Update references following rebase
SteveSandersonMS 28f7757
CR feedback: Number parsing
SteveSandersonMS f207979
CR: In InputNumber, add "type='number'" and conditional "step='any'"
SteveSandersonMS 741c0ad
CR: Remove async validation comments
SteveSandersonMS f4b77c6
CR: sealed EditContext
SteveSandersonMS c35639f
CR: Add eventargs classes
SteveSandersonMS 2d760f2
CR: Comment about EditContext storage
SteveSandersonMS 59be3b6
Use EventCallback in EditForm
SteveSandersonMS 57d4279
Eliminate events routing bug workarounds since the underlying issue i…
SteveSandersonMS 35e36b0
CR: Avoid some System.Linq allocations
SteveSandersonMS a270131
CR: Null check accessor
SteveSandersonMS 4b52cb3
CR: Use 'in' with FieldIdentifier
SteveSandersonMS fa8e377
CR: Make TryParseValueFromString abstract
SteveSandersonMS f39239b
CR: Seal ValidationMessageStore
SteveSandersonMS a072653
CR: Avoid use of .Values
SteveSandersonMS 39ab4dc
Fix Blazor test
SteveSandersonMS 283a7dc
CR: In tests, don't make assumpions about DataAnnotations messages
SteveSandersonMS 4fb9486
CR: Clean up test namespaces
SteveSandersonMS 87a7161
CR: Reorder usings
SteveSandersonMS 5573ebe
Update build config
SteveSandersonMS 454ee02
E2E test for simple validation scenario
SteveSandersonMS 1c68569
E2E tests for built-in Input* components
SteveSandersonMS 1b5f965
E2E test for INotifyPropertyChanged integration scenario
SteveSandersonMS d63f817
Add ValidationMessage component
SteveSandersonMS 2658d7e
E2E test for ValidationMessage
SteveSandersonMS 255d0e8
Update build config
SteveSandersonMS 3cc40e4
Versioning changes as requested
SteveSandersonMS 4f7bdc0
FieldIdentifier enhancements as discussed
SteveSandersonMS dd0929b
Empty commit to trigger CI
SteveSandersonMS File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
28 changes: 28 additions & 0 deletions
28
src/Components/Components/src/Forms/DataAnnotationsValidator.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
SteveSandersonMS marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
/// <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)) | ||
SteveSandersonMS marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ | ||
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; | ||
} | ||
} | ||
} |
101 changes: 101 additions & 0 deletions
101
src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
SteveSandersonMS marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
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) | ||
SteveSandersonMS marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ | ||
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; | ||
} | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.