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"); }