Make InputBase respond to validation state notifications (#14818)
* InputBase subscribes to OnValidationStateChanged. Fixes #11914 * E2E test
This commit is contained in:
parent
2c6d7a0cb8
commit
d3f1f5a6ea
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue