diff --git a/src/Components/Forms/src/EditContext.cs b/src/Components/Forms/src/EditContext.cs index ede563c55f6d..a5c5d815667d 100644 --- a/src/Components/Forms/src/EditContext.cs +++ b/src/Components/Forms/src/EditContext.cs @@ -90,10 +90,17 @@ public FieldIdentifier Field(string fieldName) /// Identifies the field whose value has been changed. public void NotifyFieldChanged(in FieldIdentifier fieldIdentifier) { - GetOrAddFieldState(fieldIdentifier).IsModified = true; + MarkAsModified(fieldIdentifier); OnFieldChanged?.Invoke(this, new FieldChangedEventArgs(fieldIdentifier)); } + /// + /// Marks the specified field as modified. + /// + /// Identifies the field whose modification flag should be set. + public void MarkAsModified(in FieldIdentifier fieldIdentifier) + => GetOrAddFieldState(fieldIdentifier).IsModified = true; + /// /// Signals that some aspect of validation state has changed. /// diff --git a/src/Components/Forms/src/PublicAPI.Unshipped.txt b/src/Components/Forms/src/PublicAPI.Unshipped.txt index d00a29c69892..692bf5a51ae6 100644 --- a/src/Components/Forms/src/PublicAPI.Unshipped.txt +++ b/src/Components/Forms/src/PublicAPI.Unshipped.txt @@ -12,3 +12,4 @@ Microsoft.AspNetCore.Components.Forms.EditContext.IsValidationPending() -> bool Microsoft.AspNetCore.Components.Forms.EditContext.IsValidationFaulted(in Microsoft.AspNetCore.Components.Forms.FieldIdentifier fieldIdentifier) -> bool Microsoft.AspNetCore.Components.Forms.EditContext.IsValidationFaulted(System.Linq.Expressions.Expression!>! accessor) -> bool Microsoft.AspNetCore.Components.Forms.EditContext.IsValidationFaulted() -> bool +Microsoft.AspNetCore.Components.Forms.EditContext.MarkAsModified(in Microsoft.AspNetCore.Components.Forms.FieldIdentifier fieldIdentifier) -> void diff --git a/src/Components/Forms/test/EditContextDataAnnotationsExtensionsTest.cs b/src/Components/Forms/test/EditContextDataAnnotationsExtensionsTest.cs index 327d3358e9dc..ba5947a74a26 100644 --- a/src/Components/Forms/test/EditContextDataAnnotationsExtensionsTest.cs +++ b/src/Components/Forms/test/EditContextDataAnnotationsExtensionsTest.cs @@ -132,6 +132,28 @@ public void PerformsPerPropertyValidationOnFieldChange() Assert.Equal(new[] { "IntFrom1To100:range" }, editContext.GetValidationMessages()); } + [Fact] + public void MarkAsModifiedDoesNotTriggerPerPropertyValidation() + { + var model = new TestModel { IntFrom1To100 = 101 }; + var editContext = new EditContext(model); + editContext.EnableDataAnnotationsValidation(_serviceProvider); + var onValidationStateChangedCount = 0; + var requiredStringIdentifier = new FieldIdentifier(model, nameof(TestModel.RequiredString)); + editContext.OnValidationStateChanged += (sender, eventArgs) => onValidationStateChangedCount++; + + editContext.MarkAsModified(requiredStringIdentifier); + + Assert.True(editContext.IsModified(requiredStringIdentifier)); + Assert.Equal(0, onValidationStateChangedCount); + Assert.Empty(editContext.GetValidationMessages(requiredStringIdentifier)); + Assert.Empty(editContext.GetValidationMessages()); + + editContext.NotifyFieldChanged(requiredStringIdentifier); + Assert.Equal(1, onValidationStateChangedCount); + Assert.Equal(new[] { "RequiredString:required" }, editContext.GetValidationMessages()); + } + [Theory] [InlineData(nameof(TestModel.ThisWillNotBeValidatedBecauseItIsAField))] [InlineData(nameof(TestModel.ThisWillNotBeValidatedBecauseItIsInternal))] diff --git a/src/Components/Forms/test/EditContextTest.cs b/src/Components/Forms/test/EditContextTest.cs index 002bf38ae888..fcd7880be6be 100644 --- a/src/Components/Forms/test/EditContextTest.cs +++ b/src/Components/Forms/test/EditContextTest.cs @@ -125,6 +125,21 @@ public void RaisesEventWhenFieldIsChanged() Assert.True(didReceiveNotification); } + [Fact] + public void MarkAsModifiedTracksFieldAsModifiedWithoutRaisingOnFieldChanged() + { + var editContext = new EditContext(new object()); + var field1 = editContext.Field("field1"); + var didReceiveNotification = false; + editContext.OnFieldChanged += (sender, eventArgs) => didReceiveNotification = true; + + editContext.MarkAsModified(field1); + + Assert.True(editContext.IsModified()); + Assert.True(editContext.IsModified(field1)); + Assert.False(didReceiveNotification); + } + [Fact] public void CanEnumerateValidationMessagesAcrossAllStoresForSingleField() { diff --git a/src/Components/Web/src/Forms/InputBase.cs b/src/Components/Web/src/Forms/InputBase.cs index cf1bb261e6fe..309d91c1b8db 100644 --- a/src/Components/Web/src/Forms/InputBase.cs +++ b/src/Components/Web/src/Forms/InputBase.cs @@ -139,8 +139,9 @@ protected string? CurrentValueAsString _parsingValidationMessages ??= new ValidationMessageStore(EditContext); _parsingValidationMessages.Add(FieldIdentifier, validationErrorMessage); - // Since we're not writing to CurrentValue, we'll need to notify about modification from here - EditContext.NotifyFieldChanged(FieldIdentifier); + // The raw input changed, but the model value did not. Track the user interaction + // without revalidating the unchanged model value. + EditContext.MarkAsModified(FieldIdentifier); } } diff --git a/src/Components/Web/test/Forms/InputBaseTest.cs b/src/Components/Web/test/Forms/InputBaseTest.cs index 5f2ae4ce135a..a5381cd19054 100644 --- a/src/Components/Web/test/Forms/InputBaseTest.cs +++ b/src/Components/Web/test/Forms/InputBaseTest.cs @@ -326,7 +326,12 @@ public async Task ParsesCurrentValueAsStringWhenChanged_Invalid() Assert.Equal(new[] { "Bad date value" }, rootComponent.EditContext.GetValidationMessages(fieldIdentifier)); Assert.Equal(1, numValidationStateChanges); - // Act/Assert 2: Transition to valid + await inputComponent.SetCurrentValueAsStringAsync("invalid"); + Assert.Empty(valueChangedArgs); + Assert.True(rootComponent.EditContext.IsModified(fieldIdentifier)); + Assert.Equal(new[] { "Bad date value" }, rootComponent.EditContext.GetValidationMessages(fieldIdentifier)); + Assert.Equal(2, numValidationStateChanges); + await inputComponent.SetCurrentValueAsStringAsync("1991/11/20"); var receivedParsedValue = valueChangedArgs.Single(); Assert.Equal(1991, receivedParsedValue.Year); @@ -334,7 +339,91 @@ public async Task ParsesCurrentValueAsStringWhenChanged_Invalid() Assert.Equal(20, receivedParsedValue.Day); Assert.True(rootComponent.EditContext.IsModified(fieldIdentifier)); Assert.Empty(rootComponent.EditContext.GetValidationMessages(fieldIdentifier)); - Assert.Equal(2, numValidationStateChanges); + Assert.Equal(3, numValidationStateChanges); + } + + [Fact] + public async Task ParsingFailureDoesNotNotifyFieldChanged() + { + var model = new TestModel(); + var editContext = new EditContext(model); + var rootComponent = new TestInputHostComponent + { + EditContext = editContext, + ValueChanged = _ => { }, + ValueExpression = () => model.DateProperty + }; + var fieldIdentifier = FieldIdentifier.Create(() => model.DateProperty); + var fieldChangedCount = 0; + editContext.OnFieldChanged += (sender, eventArgs) => fieldChangedCount++; + var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent); + + await inputComponent.SetCurrentValueAsStringAsync("1991/11/40"); + + Assert.True(editContext.IsModified(fieldIdentifier)); + Assert.Equal(0, fieldChangedCount); + Assert.Equal(new[] { "Bad date value" }, editContext.GetValidationMessages(fieldIdentifier)); + } + + [Fact] + public async Task ValidValueAfterParsingFailureNotifiesFieldChanged() + { + var model = new TestModel(); + var editContext = new EditContext(model); + var rootComponent = new TestInputHostComponent + { + EditContext = editContext, + ValueChanged = value => model.DateProperty = value, + ValueExpression = () => model.DateProperty + }; + var fieldIdentifier = FieldIdentifier.Create(() => model.DateProperty); + var fieldChangedCount = 0; + editContext.OnFieldChanged += (sender, eventArgs) => fieldChangedCount++; + var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent); + await inputComponent.SetCurrentValueAsStringAsync("1991/11/40"); + Assert.Equal(0, fieldChangedCount); + + await inputComponent.SetCurrentValueAsStringAsync("1991/11/20"); + + Assert.Equal(new DateTime(1991, 11, 20), model.DateProperty); + Assert.True(editContext.IsModified(fieldIdentifier)); + Assert.Equal(1, fieldChangedCount); + Assert.Empty(editContext.GetValidationMessages(fieldIdentifier)); + } + + [Fact] + public async Task ParsingFailureAfterValidValueDoesNotNotifyFieldChanged() + { + var model = new TestModel(); + var editContext = new EditContext(model); + var rootComponent = new TestInputHostComponent + { + EditContext = editContext, + ValueChanged = value => model.DateProperty = value, + ValueExpression = () => model.DateProperty + }; + var fieldIdentifier = FieldIdentifier.Create(() => model.DateProperty); + var unrelatedFieldIdentifier = FieldIdentifier.Create(() => model.StringProperty); + var unrelatedMessages = new ValidationMessageStore(editContext); + var fieldChangedCount = 0; + editContext.OnFieldChanged += (sender, eventArgs) => + { + fieldChangedCount++; + unrelatedMessages.Add(unrelatedFieldIdentifier, "Unrelated validation ran"); + }; + var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent); + await inputComponent.SetCurrentValueAsStringAsync("1991/11/20"); + unrelatedMessages.Clear(); + editContext.MarkAsUnmodified(fieldIdentifier); + + await inputComponent.SetCurrentValueAsStringAsync("1991/11/40"); + + Assert.Equal(new DateTime(1991, 11, 20), model.DateProperty); + Assert.True(editContext.IsModified(fieldIdentifier)); + Assert.Equal(1, fieldChangedCount); + Assert.Equal(new[] { "Bad date value" }, editContext.GetValidationMessages(fieldIdentifier)); + Assert.Empty(editContext.GetValidationMessages(unrelatedFieldIdentifier)); + Assert.False(editContext.IsModified(unrelatedFieldIdentifier)); } [Fact] diff --git a/src/Components/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs index 21a982e107c1..36a602cdf301 100644 --- a/src/Components/test/E2ETest/Tests/FormsTest.cs +++ b/src/Components/test/E2ETest/Tests/FormsTest.cs @@ -186,6 +186,43 @@ public void InputNumberInteractsWithEditContext_NullableFloat() Browser.Empty(messagesAccessor); } + [Fact] + public void InputNumberParsingFailureDoesNotTriggerModelValidation() + { + var appElement = MountTypicalValidationComponent(); + var input = appElement.FindElement(By.ClassName("required-number")).FindElement(By.TagName("input")); + var value = appElement.FindElement(By.ClassName("required-number-value")); + var messagesAccessor = CreateValidationMessagesAccessor(appElement); + + input.SendKeys("1e+10\t"); + + Browser.Equal("modified invalid", () => input.GetDomAttribute("class")); + Browser.Equal("", () => value.Text); + Browser.Equal(new[] { "The RequiredNumber field must be a number." }, messagesAccessor); + + input.Clear(); + input.SendKeys("5\t"); + + Browser.Equal("modified valid", () => input.GetDomAttribute("class")); + Browser.Equal("5", () => value.Text); + Browser.Empty(messagesAccessor); + + input.SendKeys(Keys.Control + "a"); + input.SendKeys(Keys.Delete); + input.SendKeys("1e+10\t"); + + Browser.Equal("modified invalid", () => input.GetDomAttribute("class")); + Browser.Equal("5", () => value.Text); + Browser.Equal(new[] { "The RequiredNumber field must be a number." }, messagesAccessor); + + input.Clear(); + input.SendKeys("\t"); + + Browser.Equal("modified invalid", () => input.GetDomAttribute("class")); + Browser.Equal("", () => value.Text); + Browser.Equal(new[] { "Enter a required number" }, messagesAccessor); + } + [Fact] public void InputTextAreaInteractsWithEditContext() { diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor index 27e09dd7846f..d12cfe3fe051 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor @@ -24,6 +24,10 @@

Height (optional):

+

+ Required number: + @person.RequiredNumber +

Description:

@@ -185,6 +189,9 @@ public float? OptionalHeight { get; set; } + [Required(ErrorMessage = "Enter a required number")] + public int? RequiredNumber { get; set; } + public DateTime RenewalDate { get; set; } = DateTime.Now; public DateTimeOffset? OptionalExpiryDate { get; set; }