Skip to content

Commit d3f1f5a

Browse files
SteveSandersonMSmkArtakMSFT
authored andcommitted
Make InputBase respond to validation state notifications (#14818)
* InputBase subscribes to OnValidationStateChanged. Fixes #11914 * E2E test
1 parent 2c6d7a0 commit d3f1f5a

File tree

6 files changed

+154
-8
lines changed

6 files changed

+154
-8
lines changed

src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public EditForm() { }
4242
protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { }
4343
protected override void OnParametersSet() { }
4444
}
45-
public abstract partial class InputBase<TValue> : Microsoft.AspNetCore.Components.ComponentBase
45+
public abstract partial class InputBase<TValue> : Microsoft.AspNetCore.Components.ComponentBase, System.IDisposable
4646
{
4747
protected InputBase() { }
4848
[Microsoft.AspNetCore.Components.ParameterAttribute(CaptureUnmatchedValues=true)]
@@ -58,8 +58,10 @@ protected InputBase() { }
5858
public Microsoft.AspNetCore.Components.EventCallback<TValue> ValueChanged { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
5959
[Microsoft.AspNetCore.Components.ParameterAttribute]
6060
public System.Linq.Expressions.Expression<System.Func<TValue>> ValueExpression { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
61+
protected virtual void Dispose(bool disposing) { }
6162
protected virtual string FormatValueAsString(TValue value) { throw null; }
6263
public override System.Threading.Tasks.Task SetParametersAsync(Microsoft.AspNetCore.Components.ParameterView parameters) { throw null; }
64+
void System.IDisposable.Dispose() { }
6365
protected abstract bool TryParseValueFromString(string value, out TValue result, out string validationErrorMessage);
6466
}
6567
public partial class InputCheckbox : Microsoft.AspNetCore.Components.Forms.InputBase<bool>

src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netstandard2.0.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public EditForm() { }
4242
protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { }
4343
protected override void OnParametersSet() { }
4444
}
45-
public abstract partial class InputBase<TValue> : Microsoft.AspNetCore.Components.ComponentBase
45+
public abstract partial class InputBase<TValue> : Microsoft.AspNetCore.Components.ComponentBase, System.IDisposable
4646
{
4747
protected InputBase() { }
4848
[Microsoft.AspNetCore.Components.ParameterAttribute(CaptureUnmatchedValues=true)]
@@ -58,8 +58,10 @@ protected InputBase() { }
5858
public Microsoft.AspNetCore.Components.EventCallback<TValue> ValueChanged { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
5959
[Microsoft.AspNetCore.Components.ParameterAttribute]
6060
public System.Linq.Expressions.Expression<System.Func<TValue>> ValueExpression { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
61+
protected virtual void Dispose(bool disposing) { }
6162
protected virtual string FormatValueAsString(TValue value) { throw null; }
6263
public override System.Threading.Tasks.Task SetParametersAsync(Microsoft.AspNetCore.Components.ParameterView parameters) { throw null; }
64+
void System.IDisposable.Dispose() { }
6365
protected abstract bool TryParseValueFromString(string value, out TValue result, out string validationErrorMessage);
6466
}
6567
public partial class InputCheckbox : Microsoft.AspNetCore.Components.Forms.InputBase<bool>

src/Components/Web/src/Forms/InputBase.cs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ namespace Microsoft.AspNetCore.Components.Forms
1313
/// integrates with an <see cref="Forms.EditContext"/>, which must be supplied
1414
/// as a cascading parameter.
1515
/// </summary>
16-
public abstract class InputBase<TValue> : ComponentBase
16+
public abstract class InputBase<TValue> : ComponentBase, IDisposable
1717
{
18+
private readonly EventHandler<ValidationStateChangedEventArgs> _validationStateChangedHandler;
1819
private bool _previousParsingAttemptFailed;
1920
private ValidationMessageStore _parsingValidationMessages;
2021
private Type _nullableUnderlyingType;
@@ -121,6 +122,14 @@ protected string CurrentValueAsString
121122
}
122123
}
123124

125+
/// <summary>
126+
/// Constructs an instance of <see cref="InputBase{TValue}"/>.
127+
/// </summary>
128+
protected InputBase()
129+
{
130+
_validationStateChangedHandler = (sender, eventArgs) => StateHasChanged();
131+
}
132+
124133
/// <summary>
125134
/// Formats the value as a string. Derived classes can override this to determine the formating used for <see cref="CurrentValueAsString"/>.
126135
/// </summary>
@@ -193,6 +202,8 @@ public override Task SetParametersAsync(ParameterView parameters)
193202
EditContext = CascadedEditContext;
194203
FieldIdentifier = FieldIdentifier.Create(ValueExpression);
195204
_nullableUnderlyingType = Nullable.GetUnderlyingType(typeof(TValue));
205+
206+
EditContext.OnValidationStateChanged += _validationStateChangedHandler;
196207
}
197208
else if (CascadedEditContext != EditContext)
198209
{
@@ -208,5 +219,19 @@ public override Task SetParametersAsync(ParameterView parameters)
208219
// For derived components, retain the usual lifecycle with OnInit/OnParametersSet/etc.
209220
return base.SetParametersAsync(ParameterView.Empty);
210221
}
222+
223+
protected virtual void Dispose(bool disposing)
224+
{
225+
}
226+
227+
void IDisposable.Dispose()
228+
{
229+
if (EditContext != null)
230+
{
231+
EditContext.OnValidationStateChanged -= _validationStateChangedHandler;
232+
}
233+
234+
Dispose(disposing: true);
235+
}
211236
}
212237
}

src/Components/Web/test/Forms/InputBaseTest.cs

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ public async Task ParsesCurrentValueAsStringWhenChanged_Valid()
294294
rootComponent.EditContext.OnValidationStateChanged += (sender, eventArgs) => { numValidationStateChanges++; };
295295

296296
// Act
297-
inputComponent.CurrentValueAsString = "1991/11/20";
297+
await inputComponent.SetCurrentValueAsStringAsync("1991/11/20");
298298

299299
// Assert
300300
var receivedParsedValue = valueChangedArgs.Single();
@@ -324,14 +324,14 @@ public async Task ParsesCurrentValueAsStringWhenChanged_Invalid()
324324
rootComponent.EditContext.OnValidationStateChanged += (sender, eventArgs) => { numValidationStateChanges++; };
325325

326326
// Act/Assert 1: Transition to invalid
327-
inputComponent.CurrentValueAsString = "1991/11/40";
327+
await inputComponent.SetCurrentValueAsStringAsync("1991/11/40");
328328
Assert.Empty(valueChangedArgs);
329329
Assert.True(rootComponent.EditContext.IsModified(fieldIdentifier));
330330
Assert.Equal(new[] { "Bad date value" }, rootComponent.EditContext.GetValidationMessages(fieldIdentifier));
331331
Assert.Equal(1, numValidationStateChanges);
332332

333333
// Act/Assert 2: Transition to valid
334-
inputComponent.CurrentValueAsString = "1991/11/20";
334+
await inputComponent.SetCurrentValueAsStringAsync("1991/11/20");
335335
var receivedParsedValue = valueChangedArgs.Single();
336336
Assert.Equal(1991, receivedParsedValue.Year);
337337
Assert.Equal(11, receivedParsedValue.Month);
@@ -341,6 +341,65 @@ public async Task ParsesCurrentValueAsStringWhenChanged_Invalid()
341341
Assert.Equal(2, numValidationStateChanges);
342342
}
343343

344+
[Fact]
345+
public async Task RespondsToValidationStateChangeNotifications()
346+
{
347+
// Arrange
348+
var model = new TestModel();
349+
var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
350+
{
351+
EditContext = new EditContext(model),
352+
ValueExpression = () => model.StringProperty
353+
};
354+
var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty);
355+
var renderer = new TestRenderer();
356+
var rootComponentId = renderer.AssignRootComponentId(rootComponent);
357+
await renderer.RenderRootComponentAsync(rootComponentId);
358+
359+
// Initally, it rendered one batch and is valid
360+
var batch1 = renderer.Batches.Single();
361+
var componentFrame1 = batch1.GetComponentFrames<TestInputComponent<string>>().Single();
362+
var inputComponentId = componentFrame1.ComponentId;
363+
var component = (TestInputComponent<string>)componentFrame1.Component;
364+
Assert.Equal("valid", component.CssClass);
365+
366+
// Act: update the field state in the EditContext and notify
367+
var messageStore = new ValidationMessageStore(rootComponent.EditContext);
368+
messageStore.Add(fieldIdentifier, "Some message");
369+
await renderer.Dispatcher.InvokeAsync(rootComponent.EditContext.NotifyValidationStateChanged);
370+
371+
// Assert: The input component rendered itself again and now has the new class
372+
var batch2 = renderer.Batches.Skip(1).Single();
373+
Assert.Equal(inputComponentId, batch2.DiffsByComponentId.Keys.Single());
374+
Assert.Equal("invalid", component.CssClass);
375+
}
376+
377+
[Fact]
378+
public async Task UnsubscribesFromValidationStateChangeNotifications()
379+
{
380+
// Arrange
381+
var model = new TestModel();
382+
var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>>
383+
{
384+
EditContext = new EditContext(model),
385+
ValueExpression = () => model.StringProperty
386+
};
387+
var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty);
388+
var renderer = new TestRenderer();
389+
var rootComponentId = renderer.AssignRootComponentId(rootComponent);
390+
await renderer.RenderRootComponentAsync(rootComponentId);
391+
var component = renderer.Batches.Single().GetComponentFrames<TestInputComponent<string>>().Single().Component;
392+
393+
// Act: dispose, then update the field state in the EditContext and notify
394+
((IDisposable)component).Dispose();
395+
var messageStore = new ValidationMessageStore(rootComponent.EditContext);
396+
messageStore.Add(fieldIdentifier, "Some message");
397+
await renderer.Dispatcher.InvokeAsync(rootComponent.EditContext.NotifyValidationStateChanged);
398+
399+
// Assert: No additional render
400+
Assert.Empty(renderer.Batches.Skip(1));
401+
}
402+
344403
private static TComponent FindComponent<TComponent>(CapturedBatch batch)
345404
=> batch.ReferenceFrames
346405
.Where(f => f.FrameType == RenderTreeFrameType.Component)
@@ -376,7 +435,6 @@ class TestInputComponent<T> : InputBase<T>
376435
public new string CurrentValueAsString
377436
{
378437
get => base.CurrentValueAsString;
379-
set { base.CurrentValueAsString = value; }
380438
}
381439

382440
public new IReadOnlyDictionary<string, object> AdditionalAttributes => base.AdditionalAttributes;
@@ -391,6 +449,15 @@ protected override bool TryParseValueFromString(string value, out T result, out
391449
{
392450
throw new NotImplementedException();
393451
}
452+
453+
public async Task SetCurrentValueAsStringAsync(string value)
454+
{
455+
// This is equivalent to the subclass writing to CurrentValueAsString
456+
// (e.g., from @bind), except to simplify the test code there's an InvokeAsync
457+
// here. In production code it wouldn't normally be required because @bind
458+
// calls run on the sync context anyway.
459+
await InvokeAsync(() => { base.CurrentValueAsString = value; });
460+
}
394461
}
395462

396463
class TestDateInputComponent : TestInputComponent<DateTime>

src/Components/test/E2ETest/Tests/FormsTest.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,24 @@ public void InputComponentsCauseContainerToRerenderOnChange()
376376
Browser.Equal("Premium", () => selectedTicketClassDisplay.Text);
377377
}
378378

379+
[Fact]
380+
public void InputComponentsRespondToAsynchronouslyAddedMessages()
381+
{
382+
var appElement = Browser.MountTestComponent<TypicalValidationComponent>();
383+
var input = appElement.FindElement(By.CssSelector(".username input"));
384+
var triggerAsyncErrorButton = appElement.FindElement(By.CssSelector(".username button"));
385+
var messagesAccessor = CreateValidationMessagesAccessor(appElement);
386+
387+
// Initially shows no error
388+
Browser.Empty(() => messagesAccessor());
389+
Browser.Equal("valid", () => input.GetAttribute("class"));
390+
391+
// Can trigger async error
392+
triggerAsyncErrorButton.Click();
393+
Browser.Equal(new[] { "This is invalid, asynchronously" }, messagesAccessor);
394+
Browser.Equal("invalid", () => input.GetAttribute("class"));
395+
}
396+
379397
private Func<string[]> CreateValidationMessagesAccessor(IWebElement appElement)
380398
{
381399
return () => appElement.FindElements(By.ClassName("validation-message"))

src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
@using System.ComponentModel.DataAnnotations
22
@using Microsoft.AspNetCore.Components.Forms
33

4-
<EditForm Model="@person" OnValidSubmit="@HandleValidSubmit">
4+
<EditForm EditContext="@editContext" OnValidSubmit="@HandleValidSubmit">
55
<DataAnnotationsValidator />
66

77
<p class="name">
@@ -42,6 +42,10 @@
4242
<p class="is-evil">
4343
Is evil: <InputCheckbox @bind-Value="person.IsEvil" />
4444
</p>
45+
<p class="username">
46+
Username (optional): <InputText @bind-Value="person.Username" />
47+
<button type="button" @onclick="@TriggerAsyncValidationError">Trigger async error</button>
48+
</p>
4549

4650
<button type="submit">Submit</button>
4751

@@ -52,6 +56,14 @@
5256

5357
@code {
5458
Person person = new Person();
59+
EditContext editContext;
60+
ValidationMessageStore customValidationMessageStore;
61+
62+
protected override void OnInitialized()
63+
{
64+
editContext = new EditContext(person);
65+
customValidationMessageStore = new ValidationMessageStore(editContext);
66+
}
5567

5668
// Usually this would be in a different file
5769
class Person
@@ -83,6 +95,8 @@
8395

8496
[Required, EnumDataType(typeof(TicketClass))]
8597
public TicketClass TicketClass { get; set; }
98+
99+
public string Username { get; set; }
86100
}
87101

88102
enum TicketClass { Economy, Premium, First }
@@ -93,4 +107,22 @@
93107
{
94108
submissionLog.Add("OnValidSubmit");
95109
}
110+
111+
void TriggerAsyncValidationError()
112+
{
113+
customValidationMessageStore.Clear();
114+
115+
// Note that this method returns void, so the renderer doesn't react to
116+
// its async flow by default. This is to simulate some external system
117+
// implementing async validation.
118+
Task.Run(async () =>
119+
{
120+
// The duration of the delay doesn't matter to the test, as long as it's not
121+
// so long that we time out. Picking a value that's long enough for humans
122+
// to observe the asynchrony too.
123+
await Task.Delay(500);
124+
customValidationMessageStore.Add(editContext.Field(nameof(Person.Username)), "This is invalid, asynchronously");
125+
_ = InvokeAsync(editContext.NotifyValidationStateChanged);
126+
});
127+
}
96128
}

0 commit comments

Comments
 (0)