Make InputBase respond to validation state notifications (#14818)

* InputBase subscribes to OnValidationStateChanged. Fixes #11914

* E2E test
This commit is contained in:
Steve Sanderson 2019-10-16 19:35:34 +01:00 committed by Artak
parent 2c6d7a0cb8
commit d3f1f5a6ea
6 changed files with 154 additions and 8 deletions

View File

@ -42,7 +42,7 @@ namespace Microsoft.AspNetCore.Components.Forms
protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { }
protected override void OnParametersSet() { }
}
public abstract partial class InputBase<TValue> : Microsoft.AspNetCore.Components.ComponentBase
public abstract partial class InputBase<TValue> : Microsoft.AspNetCore.Components.ComponentBase, System.IDisposable
{
protected InputBase() { }
[Microsoft.AspNetCore.Components.ParameterAttribute(CaptureUnmatchedValues=true)]
@ -58,8 +58,10 @@ namespace Microsoft.AspNetCore.Components.Forms
public Microsoft.AspNetCore.Components.EventCallback<TValue> ValueChanged { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public System.Linq.Expressions.Expression<System.Func<TValue>> 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<bool>

View File

@ -42,7 +42,7 @@ namespace Microsoft.AspNetCore.Components.Forms
protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { }
protected override void OnParametersSet() { }
}
public abstract partial class InputBase<TValue> : Microsoft.AspNetCore.Components.ComponentBase
public abstract partial class InputBase<TValue> : Microsoft.AspNetCore.Components.ComponentBase, System.IDisposable
{
protected InputBase() { }
[Microsoft.AspNetCore.Components.ParameterAttribute(CaptureUnmatchedValues=true)]
@ -58,8 +58,10 @@ namespace Microsoft.AspNetCore.Components.Forms
public Microsoft.AspNetCore.Components.EventCallback<TValue> ValueChanged { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public System.Linq.Expressions.Expression<System.Func<TValue>> 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<bool>

View File

@ -13,8 +13,9 @@ namespace Microsoft.AspNetCore.Components.Forms
/// integrates with an <see cref="Forms.EditContext"/>, which must be supplied
/// as a cascading parameter.
/// </summary>
public abstract class InputBase<TValue> : ComponentBase
public abstract class InputBase<TValue> : ComponentBase, IDisposable
{
private readonly EventHandler<ValidationStateChangedEventArgs> _validationStateChangedHandler;
private bool _previousParsingAttemptFailed;
private ValidationMessageStore _parsingValidationMessages;
private Type _nullableUnderlyingType;
@ -121,6 +122,14 @@ namespace Microsoft.AspNetCore.Components.Forms
}
}
/// <summary>
/// Constructs an instance of <see cref="InputBase{TValue}"/>.
/// </summary>
protected InputBase()
{
_validationStateChangedHandler = (sender, eventArgs) => StateHasChanged();
}
/// <summary>
/// Formats the value as a string. Derived classes can override this to determine the formating used for <see cref="CurrentValueAsString"/>.
/// </summary>
@ -193,6 +202,8 @@ namespace Microsoft.AspNetCore.Components.Forms
EditContext = CascadedEditContext;
FieldIdentifier = FieldIdentifier.Create(ValueExpression);
_nullableUnderlyingType = Nullable.GetUnderlyingType(typeof(TValue));
EditContext.OnValidationStateChanged += _validationStateChangedHandler;
}
else if (CascadedEditContext != EditContext)
{
@ -208,5 +219,19 @@ namespace Microsoft.AspNetCore.Components.Forms
// 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);
}
}
}

View File

@ -294,7 +294,7 @@ namespace Microsoft.AspNetCore.Components.Forms
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 @@ namespace Microsoft.AspNetCore.Components.Forms
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 @@ namespace Microsoft.AspNetCore.Components.Forms
Assert.Equal(2, numValidationStateChanges);
}
[Fact]
public async Task RespondsToValidationStateChangeNotifications()
{
// Arrange
var model = new TestModel();
var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
{
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<TestInputComponent<string>>().Single();
var inputComponentId = componentFrame1.ComponentId;
var component = (TestInputComponent<string>)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<string, TestInputComponent<string>>
{
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<TestInputComponent<string>>().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<TComponent>(CapturedBatch batch)
=> batch.ReferenceFrames
.Where(f => f.FrameType == RenderTreeFrameType.Component)
@ -376,7 +435,6 @@ namespace Microsoft.AspNetCore.Components.Forms
public new string CurrentValueAsString
{
get => base.CurrentValueAsString;
set { base.CurrentValueAsString = value; }
}
public new IReadOnlyDictionary<string, object> AdditionalAttributes => base.AdditionalAttributes;
@ -391,6 +449,15 @@ namespace Microsoft.AspNetCore.Components.Forms
{
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<DateTime>

View File

@ -376,6 +376,24 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
Browser.Equal("Premium", () => selectedTicketClassDisplay.Text);
}
[Fact]
public void InputComponentsRespondToAsynchronouslyAddedMessages()
{
var appElement = Browser.MountTestComponent<TypicalValidationComponent>();
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<string[]> CreateValidationMessagesAccessor(IWebElement appElement)
{
return () => appElement.FindElements(By.ClassName("validation-message"))

View File

@ -1,7 +1,7 @@
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms
<EditForm Model="@person" OnValidSubmit="@HandleValidSubmit">
<EditForm EditContext="@editContext" OnValidSubmit="@HandleValidSubmit">
<DataAnnotationsValidator />
<p class="name">
@ -42,6 +42,10 @@
<p class="is-evil">
Is evil: <InputCheckbox @bind-Value="person.IsEvil" />
</p>
<p class="username">
Username (optional): <InputText @bind-Value="person.Username" />
<button type="button" @onclick="@TriggerAsyncValidationError">Trigger async error</button>
</p>
<button type="submit">Submit</button>
@ -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);
});
}
}