diff --git a/src/Components/Forms/src/DataAnnotationsValidator.cs b/src/Components/Forms/src/DataAnnotationsValidator.cs
index 65e059d178a9..583c5886200d 100644
--- a/src/Components/Forms/src/DataAnnotationsValidator.cs
+++ b/src/Components/Forms/src/DataAnnotationsValidator.cs
@@ -8,8 +8,11 @@ namespace Microsoft.AspNetCore.Components.Forms
///
/// Adds Data Annotations validation support to an .
///
- public class DataAnnotationsValidator : ComponentBase
+ public class DataAnnotationsValidator : ComponentBase, IDisposable
{
+ private IDisposable? _subscriptions;
+ private EditContext? _originalEditContext;
+
[CascadingParameter] EditContext? CurrentEditContext { get; set; }
///
@@ -22,7 +25,33 @@ protected override void OnInitialized()
$"inside an EditForm.");
}
- CurrentEditContext.AddDataAnnotationsValidation();
+ _subscriptions = CurrentEditContext.EnableDataAnnotationsValidation();
+ _originalEditContext = CurrentEditContext;
+ }
+
+ ///
+ protected override void OnParametersSet()
+ {
+ if (CurrentEditContext != _originalEditContext)
+ {
+ // While we could support this, there's no known use case presently. Since InputBase doesn't support it,
+ // it's more understandable to have the same restriction.
+ throw new InvalidOperationException($"{GetType()} does not support changing the " +
+ $"{nameof(EditContext)} dynamically.");
+ }
+ }
+
+ ///
+ protected virtual void Dispose(bool disposing)
+ {
+ }
+
+ void IDisposable.Dispose()
+ {
+ _subscriptions?.Dispose();
+ _subscriptions = null;
+
+ Dispose(disposing: true);
}
}
}
diff --git a/src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs b/src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs
index 56c226455357..258d4cb56ea3 100644
--- a/src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs
+++ b/src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs
@@ -16,98 +16,118 @@ namespace Microsoft.AspNetCore.Components.Forms
///
public static class EditContextDataAnnotationsExtensions
{
- private static ConcurrentDictionary<(Type ModelType, string FieldName), PropertyInfo?> _propertyInfoCache
- = new ConcurrentDictionary<(Type, string), PropertyInfo?>();
-
///
/// Adds DataAnnotations validation support to the .
///
/// The .
+ [Obsolete("Use " + nameof(EnableDataAnnotationsValidation) + " instead.")]
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);
-
+ EnableDataAnnotationsValidation(editContext);
return editContext;
}
- private static void ValidateModel(EditContext editContext, ValidationMessageStore messages)
+ ///
+ /// Enables DataAnnotations validation support for the .
+ ///
+ /// The .
+ /// A disposable object whose disposal will remove DataAnnotations validation support from the .
+ public static IDisposable EnableDataAnnotationsValidation(this EditContext editContext)
+ {
+ return new DataAnnotationsEventSubscriptions(editContext);
+ }
+
+ private sealed class DataAnnotationsEventSubscriptions : IDisposable
{
- var validationContext = new ValidationContext(editContext.Model);
- var validationResults = new List();
- Validator.TryValidateObject(editContext.Model, validationContext, validationResults, true);
+ private static readonly ConcurrentDictionary<(Type ModelType, string FieldName), PropertyInfo?> _propertyInfoCache = new();
- // Transfer results to the ValidationMessageStore
- messages.Clear();
- foreach (var validationResult in validationResults)
+ private readonly EditContext _editContext;
+ private readonly ValidationMessageStore _messages;
+
+ public DataAnnotationsEventSubscriptions(EditContext editContext)
{
- if (validationResult == null)
- {
- continue;
- }
+ _editContext = editContext ?? throw new ArgumentNullException(nameof(editContext));
+ _messages = new ValidationMessageStore(_editContext);
- if (!validationResult.MemberNames.Any())
- {
- messages.Add(new FieldIdentifier(editContext.Model, fieldName: string.Empty), validationResult.ErrorMessage!);
- continue;
- }
+ _editContext.OnFieldChanged += OnFieldChanged;
+ _editContext.OnValidationRequested += OnValidationRequested;
+ }
- foreach (var memberName in validationResult.MemberNames)
+ private void OnFieldChanged(object? sender, FieldChangedEventArgs eventArgs)
+ {
+ var fieldIdentifier = eventArgs.FieldIdentifier;
+ if (TryGetValidatableProperty(fieldIdentifier, out var propertyInfo))
{
- messages.Add(editContext.Field(memberName), validationResult.ErrorMessage!);
+ var propertyValue = propertyInfo.GetValue(fieldIdentifier.Model);
+ var validationContext = new ValidationContext(fieldIdentifier.Model)
+ {
+ MemberName = propertyInfo.Name
+ };
+ var results = new List();
+
+ 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();
}
}
- editContext.NotifyValidationStateChanged();
- }
-
- private static void ValidateField(EditContext editContext, ValidationMessageStore messages, in FieldIdentifier fieldIdentifier)
- {
- if (TryGetValidatableProperty(fieldIdentifier, out var propertyInfo))
+ private void OnValidationRequested(object? sender, ValidationRequestedEventArgs e)
{
- var propertyValue = propertyInfo.GetValue(fieldIdentifier.Model);
- var validationContext = new ValidationContext(fieldIdentifier.Model)
+ var validationContext = new ValidationContext(_editContext.Model);
+ var validationResults = new List();
+ Validator.TryValidateObject(_editContext.Model, validationContext, validationResults, true);
+
+ // Transfer results to the ValidationMessageStore
+ _messages.Clear();
+ foreach (var validationResult in validationResults)
{
- MemberName = propertyInfo.Name
- };
- var results = new List();
+ if (validationResult == null)
+ {
+ continue;
+ }
+
+ 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!);
+ }
+ }
- Validator.TryValidateProperty(propertyValue, validationContext, results);
- messages.Clear(fieldIdentifier);
- messages.Add(fieldIdentifier, results.Select(result => result.ErrorMessage!));
+ _editContext.NotifyValidationStateChanged();
+ }
- // 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();
+ public void Dispose()
+ {
+ _messages.Clear();
+ _editContext.OnFieldChanged -= OnFieldChanged;
+ _editContext.OnValidationRequested -= OnValidationRequested;
+ _editContext.NotifyValidationStateChanged();
}
- }
- private static bool TryGetValidatableProperty(in FieldIdentifier fieldIdentifier, [NotNullWhen(true)] out PropertyInfo? propertyInfo)
- {
- var cacheKey = (ModelType: fieldIdentifier.Model.GetType(), fieldIdentifier.FieldName);
- if (!_propertyInfoCache.TryGetValue(cacheKey, out propertyInfo))
+ private static bool TryGetValidatableProperty(in FieldIdentifier fieldIdentifier, [NotNullWhen(true)] out PropertyInfo? 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);
+ 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;
- }
+ // No need to lock, because it doesn't matter if we write the same value twice
+ _propertyInfoCache[cacheKey] = propertyInfo;
+ }
- return propertyInfo != null;
+ return propertyInfo != null;
+ }
}
}
}
diff --git a/src/Components/Forms/src/Microsoft.AspNetCore.Components.Forms.WarningSuppressions.xml b/src/Components/Forms/src/Microsoft.AspNetCore.Components.Forms.WarningSuppressions.xml
index a1d67690b415..e19838cbe120 100644
--- a/src/Components/Forms/src/Microsoft.AspNetCore.Components.Forms.WarningSuppressions.xml
+++ b/src/Components/Forms/src/Microsoft.AspNetCore.Components.Forms.WarningSuppressions.xml
@@ -5,19 +5,19 @@
ILLink
IL2026
member
- M:Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.ValidateField(Microsoft.AspNetCore.Components.Forms.EditContext,Microsoft.AspNetCore.Components.Forms.ValidationMessageStore,Microsoft.AspNetCore.Components.Forms.FieldIdentifier@)
+ M:Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.DataAnnotationsEventSubscriptions.OnFieldChanged(System.Object,Microsoft.AspNetCore.Components.Forms.FieldChangedEventArgs)
ILLink
IL2026
member
- M:Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.ValidateModel(Microsoft.AspNetCore.Components.Forms.EditContext,Microsoft.AspNetCore.Components.Forms.ValidationMessageStore)
+ M:Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.DataAnnotationsEventSubscriptions.OnValidationRequested(System.Object,Microsoft.AspNetCore.Components.Forms.ValidationRequestedEventArgs)
ILLink
IL2080
member
- M:Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.TryGetValidatableProperty(Microsoft.AspNetCore.Components.Forms.FieldIdentifier@,System.Reflection.PropertyInfo@)
+ M:Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.DataAnnotationsEventSubscriptions.TryGetValidatableProperty(Microsoft.AspNetCore.Components.Forms.FieldIdentifier@,System.Reflection.PropertyInfo@)
\ No newline at end of file
diff --git a/src/Components/Forms/src/PublicAPI.Unshipped.txt b/src/Components/Forms/src/PublicAPI.Unshipped.txt
index 7dc5c58110bf..31beb9c94faf 100644
--- a/src/Components/Forms/src/PublicAPI.Unshipped.txt
+++ b/src/Components/Forms/src/PublicAPI.Unshipped.txt
@@ -1 +1,4 @@
#nullable enable
+override Microsoft.AspNetCore.Components.Forms.DataAnnotationsValidator.OnParametersSet() -> void
+static Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.EnableDataAnnotationsValidation(this Microsoft.AspNetCore.Components.Forms.EditContext! editContext) -> System.IDisposable!
+virtual Microsoft.AspNetCore.Components.Forms.DataAnnotationsValidator.Dispose(bool disposing) -> void
diff --git a/src/Components/Forms/test/EditContextDataAnnotationsExtensionsTest.cs b/src/Components/Forms/test/EditContextDataAnnotationsExtensionsTest.cs
index bb7837e2f08f..80feb0d20a8d 100644
--- a/src/Components/Forms/test/EditContextDataAnnotationsExtensionsTest.cs
+++ b/src/Components/Forms/test/EditContextDataAnnotationsExtensionsTest.cs
@@ -13,15 +13,17 @@ public class EditContextDataAnnotationsExtensionsTest
public void CannotUseNullEditContext()
{
var editContext = (EditContext)null;
- var ex = Assert.Throws(() => editContext.AddDataAnnotationsValidation());
+ var ex = Assert.Throws(() => editContext.EnableDataAnnotationsValidation());
Assert.Equal("editContext", ex.ParamName);
}
[Fact]
- public void ReturnsEditContextForChaining()
+ public void ObsoleteApiReturnsEditContextForChaining()
{
var editContext = new EditContext(new object());
+#pragma warning disable 0618
var returnValue = editContext.AddDataAnnotationsValidation();
+#pragma warning restore 0618
Assert.Same(editContext, returnValue);
}
@@ -30,7 +32,8 @@ public void GetsValidationMessagesFromDataAnnotations()
{
// Arrange
var model = new TestModel { IntFrom1To100 = 101 };
- var editContext = new EditContext(model).AddDataAnnotationsValidation();
+ var editContext = new EditContext(model);
+ editContext.EnableDataAnnotationsValidation();
// Act
var isValid = editContext.Validate();
@@ -59,7 +62,8 @@ public void ClearsExistingValidationMessagesOnFurtherRuns()
{
// Arrange
var model = new TestModel { IntFrom1To100 = 101 };
- var editContext = new EditContext(model).AddDataAnnotationsValidation();
+ var editContext = new EditContext(model);
+ editContext.EnableDataAnnotationsValidation();
// Act/Assert 1: Initially invalid
Assert.False(editContext.Validate());
@@ -75,7 +79,8 @@ public void NotifiesValidationStateChangedAfterObjectValidation()
{
// Arrange
var model = new TestModel { IntFrom1To100 = 101 };
- var editContext = new EditContext(model).AddDataAnnotationsValidation();
+ var editContext = new EditContext(model);
+ editContext.EnableDataAnnotationsValidation();
var onValidationStateChangedCount = 0;
editContext.OnValidationStateChanged += (sender, eventArgs) => onValidationStateChangedCount++;
@@ -102,7 +107,8 @@ public void PerformsPerPropertyValidationOnFieldChange()
// Arrange
var model = new TestModel { IntFrom1To100 = 101 };
var independentTopLevelModel = new object(); // To show we can validate things on any model, not just the top-level one
- var editContext = new EditContext(independentTopLevelModel).AddDataAnnotationsValidation();
+ var editContext = new EditContext(independentTopLevelModel);
+ editContext.EnableDataAnnotationsValidation();
var onValidationStateChangedCount = 0;
var requiredStringIdentifier = new FieldIdentifier(model, nameof(TestModel.RequiredString));
var intFrom1To100Identifier = new FieldIdentifier(model, nameof(TestModel.IntFrom1To100));
@@ -141,7 +147,8 @@ public void PerformsPerPropertyValidationOnFieldChange()
public void IgnoresFieldChangesThatDoNotCorrespondToAValidatableProperty(string fieldName)
{
// Arrange
- var editContext = new EditContext(new TestModel()).AddDataAnnotationsValidation();
+ var editContext = new EditContext(new TestModel());
+ editContext.EnableDataAnnotationsValidation();
var onValidationStateChangedCount = 0;
editContext.OnValidationStateChanged += (sender, eventArgs) => onValidationStateChangedCount++;
@@ -154,6 +161,24 @@ public void IgnoresFieldChangesThatDoNotCorrespondToAValidatableProperty(string
Assert.Equal(1, onValidationStateChangedCount);
}
+ [Fact]
+ public void CanDetachFromEditContext()
+ {
+ // Arrange
+ var model = new TestModel { IntFrom1To100 = 101 };
+ var editContext = new EditContext(model);
+ var subscription = editContext.EnableDataAnnotationsValidation();
+
+ // Act/Assert 1: when we're attached
+ Assert.False(editContext.Validate());
+ Assert.NotEmpty(editContext.GetValidationMessages());
+
+ // Act/Assert 2: when we're detached
+ subscription.Dispose();
+ Assert.True(editContext.Validate());
+ Assert.Empty(editContext.GetValidationMessages());
+ }
+
class TestModel
{
[Required(ErrorMessage = "RequiredString:required")] public string RequiredString { get; set; }
diff --git a/src/Components/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs
index b7a799f69b41..847480b55c20 100644
--- a/src/Components/test/E2ETest/Tests/FormsTest.cs
+++ b/src/Components/test/E2ETest/Tests/FormsTest.cs
@@ -572,6 +572,32 @@ public void NavigateOnSubmitWorks()
Assert.DoesNotContain(log, entry => entry.Level == LogLevel.Severe);
}
+ [Fact]
+ public void CanRemoveAndReAddDataAnnotationsSupport()
+ {
+ var appElement = MountTypicalValidationComponent();
+ var messagesAccessor = CreateValidationMessagesAccessor(appElement);
+ var nameInput = appElement.FindElement(By.ClassName("name")).FindElement(By.TagName("input"));
+ Func lastLogEntryAccessor = () => appElement.FindElement(By.CssSelector(".submission-log-entry:last-of-type")).Text;
+
+ nameInput.SendKeys("01234567890123456789\t");
+ Browser.Equal("modified invalid", () => nameInput.GetAttribute("class"));
+ Browser.Equal(new[] { "That name is too long" }, messagesAccessor);
+
+ // Remove DataAnnotations support
+ appElement.FindElement(By.Id("toggle-dataannotations")).Click();
+ Browser.Equal("DataAnnotations support is now disabled", lastLogEntryAccessor);
+ Browser.Equal("modified valid", () => nameInput.GetAttribute("class"));
+ Browser.Empty(messagesAccessor);
+
+ // Re-add DataAnnotations support
+ appElement.FindElement(By.Id("toggle-dataannotations")).Click();
+ nameInput.SendKeys("0\t");
+ Browser.Equal("DataAnnotations support is now enabled", lastLogEntryAccessor);
+ Browser.Equal("modified invalid", () => nameInput.GetAttribute("class"));
+ Browser.Equal(new[] { "That name is too long" }, messagesAccessor);
+ }
+
private Func CreateValidationMessagesAccessor(IWebElement appElement)
{
return () => appElement.FindElements(By.ClassName("validation-message"))
diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/NotifyPropertyChangedValidationComponent.razor b/src/Components/test/testassets/BasicTestApp/FormsTest/NotifyPropertyChangedValidationComponent.razor
index a257c1f2c97b..936416016ab9 100644
--- a/src/Components/test/testassets/BasicTestApp/FormsTest/NotifyPropertyChangedValidationComponent.razor
+++ b/src/Components/test/testassets/BasicTestApp/FormsTest/NotifyPropertyChangedValidationComponent.razor
@@ -41,7 +41,8 @@
protected override void OnInitialized()
{
- editContext = new EditContext(person).AddDataAnnotationsValidation();
+ editContext = new EditContext(person);
+ editContext.EnableDataAnnotationsValidation();
// Wire up INotifyPropertyChanged to the EditContext
person.PropertyChanged += (sender, eventArgs) =>
diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor
index 4f91e4653867..c6be1322d73c 100644
--- a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor
+++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor
@@ -2,7 +2,10 @@
@using Microsoft.AspNetCore.Components.Forms
-
+ @if (enableDataAnnotationsSupport)
+ {
+
+ }
Name:
@@ -89,7 +92,9 @@
-@foreach (var entry in submissionLog) { - @entry
}
+@foreach (var entry in submissionLog) { - @entry
}
+
+
@code {
protected virtual bool UseExperimentalValidator => false;
@@ -97,6 +102,7 @@
Person person = new Person();
EditContext editContext;
ValidationMessageStore customValidationMessageStore;
+ bool enableDataAnnotationsSupport = true;
protected override void OnInitialized()
{
@@ -187,4 +193,10 @@
_ = InvokeAsync(editContext.NotifyValidationStateChanged);
});
}
+
+ void ToggleDataAnnotations()
+ {
+ enableDataAnnotationsSupport = !enableDataAnnotationsSupport;
+ submissionLog.Add($"DataAnnotations support is now {(enableDataAnnotationsSupport ? "enabled" : "disabled")}");
+ }
}
diff --git a/src/Shared/E2ETesting/BrowserFixture.cs b/src/Shared/E2ETesting/BrowserFixture.cs
index 2e3838415b7c..f7d5c8d62a44 100644
--- a/src/Shared/E2ETesting/BrowserFixture.cs
+++ b/src/Shared/E2ETesting/BrowserFixture.cs
@@ -143,8 +143,8 @@ private async Task DeleteBrowserUserProfileDirectoriesAsync()
// Force language to english for tests
opts.AddUserProfilePreference("intl.accept_languages", "en");
- // Comment this out if you want to watch or interact with the browser (e.g., for debugging)
- if (!Debugger.IsAttached)
+ if (!Debugger.IsAttached &&
+ !string.Equals(Environment.GetEnvironmentVariable("E2E_TEST_VISIBLE"), "true", StringComparison.OrdinalIgnoreCase))
{
opts.AddArgument("--headless");
}