Skip to content

Make InputBase respond to validation state notifications #14818

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<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)]
Expand All @@ -58,8 +58,10 @@ protected InputBase() { }
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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<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)]
Expand All @@ -58,8 +58,10 @@ protected InputBase() { }
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>
Expand Down
27 changes: 26 additions & 1 deletion src/Components/Web/src/Forms/InputBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -121,6 +122,14 @@ protected string CurrentValueAsString
}
}

/// <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>
Expand Down Expand Up @@ -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)
{
Expand All @@ -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);
}
}
}
75 changes: 71 additions & 4 deletions src/Components/Web/test/Forms/InputBaseTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand All @@ -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<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)
Expand Down Expand Up @@ -376,7 +435,6 @@ class TestInputComponent<T> : InputBase<T>
public new string CurrentValueAsString
{
get => base.CurrentValueAsString;
set { base.CurrentValueAsString = value; }
}

public new IReadOnlyDictionary<string, object> AdditionalAttributes => base.AdditionalAttributes;
Expand All @@ -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<DateTime>
Expand Down
18 changes: 18 additions & 0 deletions src/Components/test/E2ETest/Tests/FormsTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,24 @@ public void InputComponentsCauseContainerToRerenderOnChange()
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"))
Expand Down
Original file line number Diff line number Diff line change
@@ -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">
Expand Down Expand Up @@ -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>

Expand All @@ -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
Expand Down Expand Up @@ -83,6 +95,8 @@

[Required, EnumDataType(typeof(TicketClass))]
public TicketClass TicketClass { get; set; }

public string Username { get; set; }
}

enum TicketClass { Economy, Premium, First }
Expand All @@ -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);
});
}
}