From 996cfa3e747b62850de65b2d0666e09f55835ab9 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 8 Oct 2019 12:41:01 +0100 Subject: [PATCH 1/2] InputBase subscribes to OnValidationStateChanged. Fixes #11914 --- ...ft.AspNetCore.Components.Web.netcoreapp.cs | 4 +- ...spNetCore.Components.Web.netstandard2.0.cs | 4 +- src/Components/Web/src/Forms/InputBase.cs | 27 ++++++- .../Web/test/Forms/InputBaseTest.cs | 75 ++++++++++++++++++- 4 files changed, 103 insertions(+), 7 deletions(-) diff --git a/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs b/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs index 73061225e9b3..cb686083cd9c 100644 --- a/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs +++ b/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs @@ -42,7 +42,7 @@ public EditForm() { } protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { } protected override void OnParametersSet() { } } - public abstract partial class InputBase : Microsoft.AspNetCore.Components.ComponentBase + public abstract partial class InputBase : Microsoft.AspNetCore.Components.ComponentBase, System.IDisposable { protected InputBase() { } [Microsoft.AspNetCore.Components.ParameterAttribute(CaptureUnmatchedValues=true)] @@ -58,8 +58,10 @@ protected InputBase() { } public Microsoft.AspNetCore.Components.EventCallback ValueChanged { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } [Microsoft.AspNetCore.Components.ParameterAttribute] public System.Linq.Expressions.Expression> ValueExpression { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } + protected virtual void Dispose(bool disposing) { } protected virtual string FormatValueAsString(TValue value) { throw null; } public override System.Threading.Tasks.Task SetParametersAsync(Microsoft.AspNetCore.Components.ParameterView parameters) { throw null; } + void System.IDisposable.Dispose() { } protected abstract bool TryParseValueFromString(string value, out TValue result, out string validationErrorMessage); } public partial class InputCheckbox : Microsoft.AspNetCore.Components.Forms.InputBase diff --git a/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netstandard2.0.cs b/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netstandard2.0.cs index 73061225e9b3..cb686083cd9c 100644 --- a/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netstandard2.0.cs +++ b/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netstandard2.0.cs @@ -42,7 +42,7 @@ public EditForm() { } protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { } protected override void OnParametersSet() { } } - public abstract partial class InputBase : Microsoft.AspNetCore.Components.ComponentBase + public abstract partial class InputBase : Microsoft.AspNetCore.Components.ComponentBase, System.IDisposable { protected InputBase() { } [Microsoft.AspNetCore.Components.ParameterAttribute(CaptureUnmatchedValues=true)] @@ -58,8 +58,10 @@ protected InputBase() { } public Microsoft.AspNetCore.Components.EventCallback ValueChanged { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } [Microsoft.AspNetCore.Components.ParameterAttribute] public System.Linq.Expressions.Expression> ValueExpression { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } + protected virtual void Dispose(bool disposing) { } protected virtual string FormatValueAsString(TValue value) { throw null; } public override System.Threading.Tasks.Task SetParametersAsync(Microsoft.AspNetCore.Components.ParameterView parameters) { throw null; } + void System.IDisposable.Dispose() { } protected abstract bool TryParseValueFromString(string value, out TValue result, out string validationErrorMessage); } public partial class InputCheckbox : Microsoft.AspNetCore.Components.Forms.InputBase diff --git a/src/Components/Web/src/Forms/InputBase.cs b/src/Components/Web/src/Forms/InputBase.cs index f60c3303260c..5b438ecdb663 100644 --- a/src/Components/Web/src/Forms/InputBase.cs +++ b/src/Components/Web/src/Forms/InputBase.cs @@ -13,8 +13,9 @@ namespace Microsoft.AspNetCore.Components.Forms /// integrates with an , which must be supplied /// as a cascading parameter. /// - public abstract class InputBase : ComponentBase + public abstract class InputBase : ComponentBase, IDisposable { + private readonly EventHandler _validationStateChangedHandler; private bool _previousParsingAttemptFailed; private ValidationMessageStore _parsingValidationMessages; private Type _nullableUnderlyingType; @@ -121,6 +122,14 @@ protected string CurrentValueAsString } } + /// + /// Constructs an instance of . + /// + protected InputBase() + { + _validationStateChangedHandler = (sender, eventArgs) => StateHasChanged(); + } + /// /// Formats the value as a string. Derived classes can override this to determine the formating used for . /// @@ -193,6 +202,8 @@ public override Task SetParametersAsync(ParameterView parameters) EditContext = CascadedEditContext; FieldIdentifier = FieldIdentifier.Create(ValueExpression); _nullableUnderlyingType = Nullable.GetUnderlyingType(typeof(TValue)); + + EditContext.OnValidationStateChanged += _validationStateChangedHandler; } else if (CascadedEditContext != EditContext) { @@ -208,5 +219,19 @@ public override Task SetParametersAsync(ParameterView parameters) // For derived components, retain the usual lifecycle with OnInit/OnParametersSet/etc. return base.SetParametersAsync(ParameterView.Empty); } + + protected virtual void Dispose(bool disposing) + { + } + + void IDisposable.Dispose() + { + if (EditContext != null) + { + EditContext.OnValidationStateChanged -= _validationStateChangedHandler; + } + + Dispose(disposing: true); + } } } diff --git a/src/Components/Web/test/Forms/InputBaseTest.cs b/src/Components/Web/test/Forms/InputBaseTest.cs index 570b0f9283eb..ce1bf7bfa2e1 100644 --- a/src/Components/Web/test/Forms/InputBaseTest.cs +++ b/src/Components/Web/test/Forms/InputBaseTest.cs @@ -294,7 +294,7 @@ public async Task ParsesCurrentValueAsStringWhenChanged_Valid() rootComponent.EditContext.OnValidationStateChanged += (sender, eventArgs) => { numValidationStateChanges++; }; // Act - inputComponent.CurrentValueAsString = "1991/11/20"; + await inputComponent.SetCurrentValueAsStringAsync("1991/11/20"); // Assert var receivedParsedValue = valueChangedArgs.Single(); @@ -324,14 +324,14 @@ public async Task ParsesCurrentValueAsStringWhenChanged_Invalid() rootComponent.EditContext.OnValidationStateChanged += (sender, eventArgs) => { numValidationStateChanges++; }; // Act/Assert 1: Transition to invalid - inputComponent.CurrentValueAsString = "1991/11/40"; + await inputComponent.SetCurrentValueAsStringAsync("1991/11/40"); Assert.Empty(valueChangedArgs); Assert.True(rootComponent.EditContext.IsModified(fieldIdentifier)); Assert.Equal(new[] { "Bad date value" }, rootComponent.EditContext.GetValidationMessages(fieldIdentifier)); Assert.Equal(1, numValidationStateChanges); // Act/Assert 2: Transition to valid - inputComponent.CurrentValueAsString = "1991/11/20"; + await inputComponent.SetCurrentValueAsStringAsync("1991/11/20"); var receivedParsedValue = valueChangedArgs.Single(); Assert.Equal(1991, receivedParsedValue.Year); Assert.Equal(11, receivedParsedValue.Month); @@ -341,6 +341,65 @@ public async Task ParsesCurrentValueAsStringWhenChanged_Invalid() Assert.Equal(2, numValidationStateChanges); } + [Fact] + public async Task RespondsToValidationStateChangeNotifications() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent> + { + EditContext = new EditContext(model), + ValueExpression = () => model.StringProperty + }; + var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty); + var renderer = new TestRenderer(); + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + await renderer.RenderRootComponentAsync(rootComponentId); + + // Initally, it rendered one batch and is valid + var batch1 = renderer.Batches.Single(); + var componentFrame1 = batch1.GetComponentFrames>().Single(); + var inputComponentId = componentFrame1.ComponentId; + var component = (TestInputComponent)componentFrame1.Component; + Assert.Equal("valid", component.CssClass); + + // Act: update the field state in the EditContext and notify + var messageStore = new ValidationMessageStore(rootComponent.EditContext); + messageStore.Add(fieldIdentifier, "Some message"); + await renderer.Dispatcher.InvokeAsync(rootComponent.EditContext.NotifyValidationStateChanged); + + // Assert: The input component rendered itself again and now has the new class + var batch2 = renderer.Batches.Skip(1).Single(); + Assert.Equal(inputComponentId, batch2.DiffsByComponentId.Keys.Single()); + Assert.Equal("invalid", component.CssClass); + } + + [Fact] + public async Task UnsubscribesFromValidationStateChangeNotifications() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent> + { + EditContext = new EditContext(model), + ValueExpression = () => model.StringProperty + }; + var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty); + var renderer = new TestRenderer(); + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + await renderer.RenderRootComponentAsync(rootComponentId); + var component = renderer.Batches.Single().GetComponentFrames>().Single().Component; + + // Act: dispose, then update the field state in the EditContext and notify + ((IDisposable)component).Dispose(); + var messageStore = new ValidationMessageStore(rootComponent.EditContext); + messageStore.Add(fieldIdentifier, "Some message"); + await renderer.Dispatcher.InvokeAsync(rootComponent.EditContext.NotifyValidationStateChanged); + + // Assert: No additional render + Assert.Empty(renderer.Batches.Skip(1)); + } + private static TComponent FindComponent(CapturedBatch batch) => batch.ReferenceFrames .Where(f => f.FrameType == RenderTreeFrameType.Component) @@ -376,7 +435,6 @@ class TestInputComponent : InputBase public new string CurrentValueAsString { get => base.CurrentValueAsString; - set { base.CurrentValueAsString = value; } } public new IReadOnlyDictionary AdditionalAttributes => base.AdditionalAttributes; @@ -391,6 +449,15 @@ protected override bool TryParseValueFromString(string value, out T result, out { throw new NotImplementedException(); } + + public async Task SetCurrentValueAsStringAsync(string value) + { + // This is equivalent to the subclass writing to CurrentValueAsString + // (e.g., from @bind), except to simplify the test code there's an InvokeAsync + // here. In production code it wouldn't normally be required because @bind + // calls run on the sync context anyway. + await InvokeAsync(() => { base.CurrentValueAsString = value; }); + } } class TestDateInputComponent : TestInputComponent From 6b3e7af081a7fa08bd05d3dd4dc78925be50a354 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 8 Oct 2019 13:15:05 +0100 Subject: [PATCH 2/2] E2E test --- .../test/E2ETest/Tests/FormsTest.cs | 18 ++++++++++ .../TypicalValidationComponent.razor | 34 ++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/Components/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs index d2108dbec774..2d622ba8642d 100644 --- a/src/Components/test/E2ETest/Tests/FormsTest.cs +++ b/src/Components/test/E2ETest/Tests/FormsTest.cs @@ -376,6 +376,24 @@ public void InputComponentsCauseContainerToRerenderOnChange() Browser.Equal("Premium", () => selectedTicketClassDisplay.Text); } + [Fact] + public void InputComponentsRespondToAsynchronouslyAddedMessages() + { + var appElement = Browser.MountTestComponent(); + var input = appElement.FindElement(By.CssSelector(".username input")); + var triggerAsyncErrorButton = appElement.FindElement(By.CssSelector(".username button")); + var messagesAccessor = CreateValidationMessagesAccessor(appElement); + + // Initially shows no error + Browser.Empty(() => messagesAccessor()); + Browser.Equal("valid", () => input.GetAttribute("class")); + + // Can trigger async error + triggerAsyncErrorButton.Click(); + Browser.Equal(new[] { "This is invalid, asynchronously" }, messagesAccessor); + Browser.Equal("invalid", () => input.GetAttribute("class")); + } + private Func CreateValidationMessagesAccessor(IWebElement appElement) { return () => appElement.FindElements(By.ClassName("validation-message")) diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor index 9c8467b8c80f..1bd3b748d100 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor @@ -1,7 +1,7 @@ @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Components.Forms - +

@@ -42,6 +42,10 @@

Is evil:

+

+ Username (optional): + +

@@ -52,6 +56,14 @@ @code { Person person = new Person(); + EditContext editContext; + ValidationMessageStore customValidationMessageStore; + + protected override void OnInitialized() + { + editContext = new EditContext(person); + customValidationMessageStore = new ValidationMessageStore(editContext); + } // Usually this would be in a different file class Person @@ -83,6 +95,8 @@ [Required, EnumDataType(typeof(TicketClass))] public TicketClass TicketClass { get; set; } + + public string Username { get; set; } } enum TicketClass { Economy, Premium, First } @@ -93,4 +107,22 @@ { submissionLog.Add("OnValidSubmit"); } + + void TriggerAsyncValidationError() + { + customValidationMessageStore.Clear(); + + // Note that this method returns void, so the renderer doesn't react to + // its async flow by default. This is to simulate some external system + // implementing async validation. + Task.Run(async () => + { + // The duration of the delay doesn't matter to the test, as long as it's not + // so long that we time out. Picking a value that's long enough for humans + // to observe the asynchrony too. + await Task.Delay(500); + customValidationMessageStore.Add(editContext.Field(nameof(Person.Username)), "This is invalid, asynchronously"); + _ = InvokeAsync(editContext.NotifyValidationStateChanged); + }); + } }