Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
5 changes: 4 additions & 1 deletion src/Components/Forms/src/EditContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,13 @@ public FieldIdentifier Field(string fieldName)
/// <param name="fieldIdentifier">Identifies the field whose value has been changed.</param>
public void NotifyFieldChanged(in FieldIdentifier fieldIdentifier)
{
GetOrAddFieldState(fieldIdentifier).IsModified = true;
MarkAsModified(fieldIdentifier);
OnFieldChanged?.Invoke(this, new FieldChangedEventArgs(fieldIdentifier));
}

internal void MarkAsModified(in FieldIdentifier fieldIdentifier)
=> GetOrAddFieldState(fieldIdentifier).IsModified = true;

/// <summary>
/// Signals that some aspect of validation state has changed.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,28 @@ public void PerformsPerPropertyValidationOnFieldChange()
Assert.Equal(new[] { "IntFrom1To100:range" }, editContext.GetValidationMessages());
}

[Fact]
public void MarkAsModifiedDoesNotTriggerPerPropertyValidation()
{
var model = new TestModel { IntFrom1To100 = 101 };
var editContext = new EditContext(model);
editContext.EnableDataAnnotationsValidation(_serviceProvider);
var onValidationStateChangedCount = 0;
var requiredStringIdentifier = new FieldIdentifier(model, nameof(TestModel.RequiredString));
editContext.OnValidationStateChanged += (sender, eventArgs) => onValidationStateChangedCount++;

editContext.MarkAsModified(requiredStringIdentifier);

Assert.True(editContext.IsModified(requiredStringIdentifier));
Assert.Equal(0, onValidationStateChangedCount);
Assert.Empty(editContext.GetValidationMessages(requiredStringIdentifier));
Assert.Empty(editContext.GetValidationMessages());

editContext.NotifyFieldChanged(requiredStringIdentifier);
Assert.Equal(1, onValidationStateChangedCount);
Assert.Equal(new[] { "RequiredString:required" }, editContext.GetValidationMessages());
}

[Theory]
[InlineData(nameof(TestModel.ThisWillNotBeValidatedBecauseItIsAField))]
[InlineData(nameof(TestModel.ThisWillNotBeValidatedBecauseItIsInternal))]
Expand Down
15 changes: 15 additions & 0 deletions src/Components/Forms/test/EditContextTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,21 @@ public void RaisesEventWhenFieldIsChanged()
Assert.True(didReceiveNotification);
}

[Fact]
public void MarkAsModifiedTracksFieldAsModifiedWithoutRaisingOnFieldChanged()
{
var editContext = new EditContext(new object());
var field1 = editContext.Field("field1");
var didReceiveNotification = false;
editContext.OnFieldChanged += (sender, eventArgs) => didReceiveNotification = true;

editContext.MarkAsModified(field1);

Assert.True(editContext.IsModified());
Assert.True(editContext.IsModified(field1));
Assert.False(didReceiveNotification);
}

[Fact]
public void CanEnumerateValidationMessagesAcrossAllStoresForSingleField()
{
Expand Down
12 changes: 12 additions & 0 deletions src/Components/Web/src/Forms/EditContextAccessor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Runtime.CompilerServices;

namespace Microsoft.AspNetCore.Components.Forms;

internal static class EditContextAccessor
{
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "MarkAsModified")]
internal static extern void MarkAsModified(EditContext editContext, in FieldIdentifier fieldIdentifier);
}
Comment thread
PureWeen marked this conversation as resolved.
Resolved
5 changes: 3 additions & 2 deletions src/Components/Web/src/Forms/InputBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,9 @@ protected string? CurrentValueAsString
_parsingValidationMessages ??= new ValidationMessageStore(EditContext);
_parsingValidationMessages.Add(FieldIdentifier, validationErrorMessage);

// Since we're not writing to CurrentValue, we'll need to notify about modification from here
EditContext.NotifyFieldChanged(FieldIdentifier);
// The raw input changed, but the model value did not. Track the user interaction
// without revalidating the unchanged model value.
EditContextAccessor.MarkAsModified(EditContext, FieldIdentifier);
}
}

Expand Down
93 changes: 91 additions & 2 deletions src/Components/Web/test/Forms/InputBaseTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -326,15 +326,104 @@ public async Task ParsesCurrentValueAsStringWhenChanged_Invalid()
Assert.Equal(new[] { "Bad date value" }, rootComponent.EditContext.GetValidationMessages(fieldIdentifier));
Assert.Equal(1, numValidationStateChanges);

// Act/Assert 2: Transition to valid
await inputComponent.SetCurrentValueAsStringAsync("invalid");
Assert.Empty(valueChangedArgs);
Assert.True(rootComponent.EditContext.IsModified(fieldIdentifier));
Assert.Equal(new[] { "Bad date value" }, rootComponent.EditContext.GetValidationMessages(fieldIdentifier));
Assert.Equal(2, numValidationStateChanges);

await inputComponent.SetCurrentValueAsStringAsync("1991/11/20");
var receivedParsedValue = valueChangedArgs.Single();
Assert.Equal(1991, receivedParsedValue.Year);
Assert.Equal(11, receivedParsedValue.Month);
Assert.Equal(20, receivedParsedValue.Day);
Assert.True(rootComponent.EditContext.IsModified(fieldIdentifier));
Assert.Empty(rootComponent.EditContext.GetValidationMessages(fieldIdentifier));
Assert.Equal(2, numValidationStateChanges);
Assert.Equal(3, numValidationStateChanges);
}

[Fact]
public async Task ParsingFailureDoesNotNotifyFieldChanged()
{
var model = new TestModel();
var editContext = new EditContext(model);
var rootComponent = new TestInputHostComponent<DateTime, TestDateInputComponent>
{
EditContext = editContext,
ValueChanged = _ => { },
ValueExpression = () => model.DateProperty
};
var fieldIdentifier = FieldIdentifier.Create(() => model.DateProperty);
var fieldChangedCount = 0;
editContext.OnFieldChanged += (sender, eventArgs) => fieldChangedCount++;
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);

await inputComponent.SetCurrentValueAsStringAsync("1991/11/40");

Assert.True(editContext.IsModified(fieldIdentifier));
Assert.Equal(0, fieldChangedCount);
Assert.Equal(new[] { "Bad date value" }, editContext.GetValidationMessages(fieldIdentifier));
}

[Fact]
public async Task ValidValueAfterParsingFailureNotifiesFieldChanged()
{
var model = new TestModel();
var editContext = new EditContext(model);
var rootComponent = new TestInputHostComponent<DateTime, TestDateInputComponent>
{
EditContext = editContext,
ValueChanged = value => model.DateProperty = value,
ValueExpression = () => model.DateProperty
};
var fieldIdentifier = FieldIdentifier.Create(() => model.DateProperty);
var fieldChangedCount = 0;
editContext.OnFieldChanged += (sender, eventArgs) => fieldChangedCount++;
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
await inputComponent.SetCurrentValueAsStringAsync("1991/11/40");
Assert.Equal(0, fieldChangedCount);

await inputComponent.SetCurrentValueAsStringAsync("1991/11/20");

Assert.Equal(new DateTime(1991, 11, 20), model.DateProperty);
Assert.True(editContext.IsModified(fieldIdentifier));
Assert.Equal(1, fieldChangedCount);
Assert.Empty(editContext.GetValidationMessages(fieldIdentifier));
}

[Fact]
public async Task ParsingFailureAfterValidValueDoesNotNotifyFieldChanged()
{
var model = new TestModel();
var editContext = new EditContext(model);
var rootComponent = new TestInputHostComponent<DateTime, TestDateInputComponent>
{
EditContext = editContext,
ValueChanged = value => model.DateProperty = value,
ValueExpression = () => model.DateProperty
};
var fieldIdentifier = FieldIdentifier.Create(() => model.DateProperty);
var unrelatedFieldIdentifier = FieldIdentifier.Create(() => model.StringProperty);
var unrelatedMessages = new ValidationMessageStore(editContext);
var fieldChangedCount = 0;
editContext.OnFieldChanged += (sender, eventArgs) =>
{
fieldChangedCount++;
unrelatedMessages.Add(unrelatedFieldIdentifier, "Unrelated validation ran");
};
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
await inputComponent.SetCurrentValueAsStringAsync("1991/11/20");
unrelatedMessages.Clear();
editContext.MarkAsUnmodified(fieldIdentifier);

await inputComponent.SetCurrentValueAsStringAsync("1991/11/40");

Assert.Equal(new DateTime(1991, 11, 20), model.DateProperty);
Assert.True(editContext.IsModified(fieldIdentifier));
Assert.Equal(1, fieldChangedCount);
Assert.Equal(new[] { "Bad date value" }, editContext.GetValidationMessages(fieldIdentifier));
Assert.Empty(editContext.GetValidationMessages(unrelatedFieldIdentifier));
Assert.False(editContext.IsModified(unrelatedFieldIdentifier));
}

[Fact]
Expand Down
37 changes: 37 additions & 0 deletions src/Components/test/E2ETest/Tests/FormsTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,43 @@ public void InputNumberInteractsWithEditContext_NullableFloat()
Browser.Empty(messagesAccessor);
}

[Fact]
public void InputNumberParsingFailureDoesNotTriggerModelValidation()
{
var appElement = MountTypicalValidationComponent();
var input = appElement.FindElement(By.ClassName("required-number")).FindElement(By.TagName("input"));
var value = appElement.FindElement(By.ClassName("required-number-value"));
var messagesAccessor = CreateValidationMessagesAccessor(appElement);

input.SendKeys("1e+10\t");

Browser.Equal("modified invalid", () => input.GetDomAttribute("class"));
Browser.Equal("", () => value.Text);
Browser.Equal(new[] { "The RequiredNumber field must be a number." }, messagesAccessor);

input.Clear();
input.SendKeys("5\t");

Browser.Equal("modified valid", () => input.GetDomAttribute("class"));
Browser.Equal("5", () => value.Text);
Browser.Empty(messagesAccessor);

input.SendKeys(Keys.Control + "a");
input.SendKeys(Keys.Delete);
input.SendKeys("1e+10\t");

Browser.Equal("modified invalid", () => input.GetDomAttribute("class"));
Browser.Equal("5", () => value.Text);
Browser.Equal(new[] { "The RequiredNumber field must be a number." }, messagesAccessor);

input.Clear();
input.SendKeys("\t");

Browser.Equal("modified invalid", () => input.GetDomAttribute("class"));
Browser.Equal("", () => value.Text);
Browser.Equal(new[] { "Enter a required number" }, messagesAccessor);
}

[Fact]
public void InputTextAreaInteractsWithEditContext()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
<p class="height">
Height (optional): <InputNumber @bind-Value="person.OptionalHeight" />
</p>
<p class="required-number">
Required number: <InputNumber @bind-Value="person.RequiredNumber" />
<span class="required-number-value">@person.RequiredNumber</span>
</p>
<p class="description">
Description: <InputTextArea @bind-Value="person.Description" placeholder="Tell us about yourself" />
</p>
Expand Down Expand Up @@ -185,6 +189,9 @@

public float? OptionalHeight { get; set; }

[Required(ErrorMessage = "Enter a required number")]
public int? RequiredNumber { get; set; }

public DateTime RenewalDate { get; set; } = DateTime.Now;

public DateTimeOffset? OptionalExpiryDate { get; set; }
Expand Down
Loading