Skip to content
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
51 changes: 40 additions & 11 deletions src/Components/Web/src/Forms/ExpressionMemberAccessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Collections.Concurrent;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq.Expressions;
using System.Reflection;
Expand Down Expand Up @@ -31,26 +32,37 @@ private static MemberInfo GetMemberInfo<TValue>(Expression<Func<TValue>> accesso
return _memberInfoCache.GetOrAdd(accessor, static expr =>
{
var lambdaExpression = (LambdaExpression)expr;
var accessorBody = lambdaExpression.Body;

if (accessorBody is UnaryExpression unaryExpression
&& unaryExpression.NodeType == ExpressionType.Convert
&& unaryExpression.Type == typeof(object))
{
accessorBody = unaryExpression.Operand;
}

if (accessorBody is not MemberExpression memberExpression)
var member = GetMemberInfo(lambdaExpression.Body, out var accessorBody);
if (member is null)
{
throw new ArgumentException(
$"The provided expression contains a {accessorBody.GetType().Name} which is not supported. " +
$"Only simple member accessors (fields, properties) of an object are supported.");
}

return memberExpression.Member;
return member;
});
}

private static MemberInfo? GetMemberInfo(Expression accessorBody, out Expression normalizedAccessorBody)
{
normalizedAccessorBody = accessorBody;

if (normalizedAccessorBody is UnaryExpression
{
NodeType: ExpressionType.Convert,
Type: var type
} unaryExpression &&
type == typeof(object))
{
normalizedAccessorBody = unaryExpression.Operand;
}

return normalizedAccessorBody is MemberExpression memberExpression
? memberExpression.Member
: null;
}

public static string GetDisplayName(MemberInfo member)
{
ArgumentNullException.ThrowIfNull(member);
Expand Down Expand Up @@ -84,6 +96,23 @@ public static string GetDisplayName<TValue>(Expression<Func<TValue>> accessor)
return GetDisplayName(member);
}

public static bool TryGetDisplayName<TValue>(
Expression<Func<TValue>> accessor,
[NotNullWhen(true)] out string? displayName)
{
ArgumentNullException.ThrowIfNull(accessor);

var member = GetMemberInfo(accessor.Body, out _);
if (member is null)
{
displayName = null;
return false;
}

displayName = GetDisplayName(member);
return true;
}

private static void ClearCache()
{
_memberInfoCache.Clear();
Expand Down
12 changes: 12 additions & 0 deletions src/Components/Web/src/Forms/InputBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ public abstract class InputBase<TValue> : ComponentBase, IDisposable
/// </summary>
[Parameter] public string? DisplayName { get; set; }

internal string GetDisplayName()
{
if (DisplayName is not null)
{
return DisplayName;
}

return ExpressionMemberAccessor.TryGetDisplayName(ValueExpression!, out var displayName)
? displayName
: FieldIdentifier.FieldName;
}
Comment thread
ilonatommy marked this conversation as resolved.

/// <summary>
/// Gets the associated <see cref="Forms.EditContext"/>.
/// This property is uninitialized if the input does not have a parent <see cref="EditForm"/>.
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web/src/Forms/InputDate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(fa
}
else
{
validationErrorMessage = string.Format(CultureInfo.InvariantCulture, _parsingErrorMessage, DisplayName ?? FieldIdentifier.FieldName);
validationErrorMessage = string.Format(CultureInfo.InvariantCulture, _parsingErrorMessage, GetDisplayName());
Comment thread
ilonatommy marked this conversation as resolved.
return false;
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web/src/Forms/InputExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ internal static class InputExtensions
}

result = default;
validationErrorMessage = $"The {input.DisplayName ?? input.FieldIdentifier.FieldName} field is not valid.";
validationErrorMessage = $"The {input.GetDisplayName()} field is not valid.";
Comment thread
ilonatommy marked this conversation as resolved.
return false;
}
catch (InvalidOperationException ex)
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web/src/Forms/InputNumber.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(fa
}
else
{
validationErrorMessage = string.Format(CultureInfo.InvariantCulture, ParsingErrorMessage, DisplayName ?? FieldIdentifier.FieldName);
validationErrorMessage = string.Format(CultureInfo.InvariantCulture, ParsingErrorMessage, GetDisplayName());
return false;
}
}
Expand Down
22 changes: 21 additions & 1 deletion src/Components/Web/test/Forms/InputDateTest.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.AspNetCore.Components.Test.Helpers;

Expand All @@ -11,7 +12,7 @@ public class InputDateTest
private readonly TestRenderer _testRenderer = new TestRenderer();

[Fact]
public async Task ValidationErrorUsesDisplayAttributeName()
public async Task ValidationErrorUsesExplicitDisplayName()
{
// Arrange
var model = new TestModel();
Expand All @@ -36,6 +37,24 @@ public async Task ValidationErrorUsesDisplayAttributeName()
Assert.Contains("The Date property field must be a date.", validationMessages);
}

[Fact]
public async Task ValidationErrorUsesDisplayAttributeOnModel()
{
var model = new TestModel();
var rootComponent = new TestInputHostComponent<DateTime, TestInputDateComponent>
{
EditContext = new EditContext(model),
ValueExpression = () => model.DateProperty,
};
var fieldIdentifier = FieldIdentifier.Create(() => model.DateProperty);
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);

await inputComponent.SetCurrentValueAsStringAsync("invalidDate");

var validationMessages = rootComponent.EditContext.GetValidationMessages(fieldIdentifier);
Assert.Contains("The Date from attribute field must be a date.", validationMessages);
}

[Fact]
public async Task InputElementIsAssignedSuccessfully()
{
Expand Down Expand Up @@ -99,6 +118,7 @@ private async Task<int> RenderAndGetInputDateComponentIdAsync(TestInputHostCompo

private class TestModel
{
[Display(Name = "Date from attribute")]
public DateTime DateProperty { get; set; }
}

Expand Down
53 changes: 50 additions & 3 deletions src/Components/Web/test/Forms/InputNumberTest.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Linq.Expressions;
using Microsoft.AspNetCore.Components.Forms.Mapping;
using Microsoft.AspNetCore.Components.Infrastructure;
using Microsoft.AspNetCore.Components.RenderTree;
Expand All @@ -21,20 +24,20 @@ public InputNumberTest()
}

[Fact]
public async Task ValidationErrorUsesDisplayAttributeName()
public async Task ValidationErrorUsesExplicitDisplayName()
{
// Arrange
var model = new TestModel();
var rootComponent = new TestInputHostComponent<int, TestInputNumberComponent>
{
EditContext = new EditContext(model),
ValueExpression = () => model.SomeNumber,
ValueExpression = () => model.NumberWithDisplayAttribute,
AdditionalAttributes = new Dictionary<string, object>
{
{ "DisplayName", "Some number" }
}
};
var fieldIdentifier = FieldIdentifier.Create(() => model.SomeNumber);
var fieldIdentifier = FieldIdentifier.Create(() => model.NumberWithDisplayAttribute);
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);

// Act
Expand All @@ -46,6 +49,26 @@ public async Task ValidationErrorUsesDisplayAttributeName()
Assert.Contains("The Some number field must be a number.", validationMessages);
}

[Fact]
public Task ValidationErrorUsesDisplayAttributeOnModel()
{
var model = new TestModel();

return AssertValidationErrorUsesDisplayName(
() => model.NumberWithDisplayAttribute,
"The Display attribute number field must be a number.");
}

[Fact]
public Task ValidationErrorUsesDisplayNameAttributeName()
{
var model = new TestModel();

return AssertValidationErrorUsesDisplayName(
() => model.NumberWithDisplayNameAttribute,
"The DisplayName attribute number field must be a number.");
}

[Fact]
public async Task InputElementIsAssignedSuccessfully()
{
Expand Down Expand Up @@ -128,6 +151,24 @@ public async Task ExplicitIdOverridesGenerated()
Assert.Equal("custom-number-id", idAttribute.AttributeValue);
}

private static async Task AssertValidationErrorUsesDisplayName(
Expression<Func<int>> valueExpression,
string expectedValidationMessage)
{
var fieldIdentifier = FieldIdentifier.Create(valueExpression);
var rootComponent = new TestInputHostComponent<int, TestInputNumberComponent>
{
EditContext = new EditContext(fieldIdentifier.Model),
ValueExpression = valueExpression,
};
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);

await inputComponent.SetCurrentValueAsStringAsync("notANumber");

var validationMessages = rootComponent.EditContext.GetValidationMessages(fieldIdentifier);
Assert.Contains(expectedValidationMessage, validationMessages);
}

private async Task<int> RenderAndGetTestInputNumberComponentIdAsync(TestInputHostComponent<int, TestInputNumberComponent> hostComponent)
{
var hostComponentId = _testRenderer.AssignRootComponentId(hostComponent);
Expand All @@ -139,6 +180,12 @@ private async Task<int> RenderAndGetTestInputNumberComponentIdAsync(TestInputHos
private class TestModel
{
public int SomeNumber { get; set; }

[Display(Name = "Display attribute number")]
public int NumberWithDisplayAttribute { get; set; }

[DisplayName("DisplayName attribute number")]
public int NumberWithDisplayNameAttribute { get; set; }
}

private class TestInputNumberComponent : InputNumber<int>
Expand Down
28 changes: 28 additions & 0 deletions src/Components/Web/test/Forms/InputRadioTest.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.ComponentModel.DataAnnotations;
using System.Linq.Expressions;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.RenderTree;
Expand Down Expand Up @@ -73,6 +74,24 @@ public async Task InputElementIsAssignedSuccessfully()
Assert.All(inputRadioComponents, inputRadio => Assert.NotNull(inputRadio.Element));
}

[Fact]
public async Task ValidationErrorUsesDisplayAttributeOnModel()
{
var model = new TestModel();
var rootComponent = new TestInputHostComponent<TestEnum, TestInputRadioGroup>
{
EditContext = new EditContext(model),
ValueExpression = () => model.TestEnum,
};
var fieldIdentifier = FieldIdentifier.Create(() => model.TestEnum);
var inputRadioGroup = await InputRenderer.RenderAndGetComponent(rootComponent);

await inputRadioGroup.SetCurrentValueAsStringAsync("invalidValue");

var validationMessages = rootComponent.EditContext.GetValidationMessages(fieldIdentifier);
Assert.Contains("The Radio choice field is not valid.", validationMessages);
}

private static RenderFragment RadioButtonsWithoutGroup(string name) => (builder) =>
{
foreach (var selectedValue in (TestEnum[])Enum.GetValues(typeof(TestEnum)))
Expand Down Expand Up @@ -125,9 +144,18 @@ private enum TestEnum

private class TestModel
{
[Display(Name = "Radio choice")]
public TestEnum TestEnum { get; set; }
}

private class TestInputRadioGroup : InputRadioGroup<TestEnum>
{
public async Task SetCurrentValueAsStringAsync(string value)
{
await InvokeAsync(() => { base.CurrentValueAsString = value; });
}
}

private class TestInputRadio : InputRadio<TestEnum>
{
public string GroupName => Context.GroupName;
Expand Down
22 changes: 21 additions & 1 deletion src/Components/Web/test/Forms/InputSelectTest.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.AspNetCore.Components.Test.Helpers;

Expand Down Expand Up @@ -169,7 +170,7 @@ public async Task ParsesCurrentValueWhenUsingNullableInt()
}

[Fact]
public async Task ValidationErrorUsesDisplayAttributeName()
public async Task ValidationErrorUsesExplicitDisplayName()
{
// Arrange
var model = new TestModel();
Expand All @@ -194,6 +195,24 @@ public async Task ValidationErrorUsesDisplayAttributeName()
Assert.Contains("The Some number field is not valid.", validationMessages);
}

[Fact]
public async Task ValidationErrorUsesDisplayAttributeOnModel()
{
var model = new TestModel();
var rootComponent = new TestInputHostComponent<int, TestInputSelect<int>>
{
EditContext = new EditContext(model),
ValueExpression = () => model.NotNullableInt,
};
var fieldIdentifier = FieldIdentifier.Create(() => model.NotNullableInt);
var inputSelectComponent = await InputRenderer.RenderAndGetComponent(rootComponent);

await inputSelectComponent.SetCurrentValueAsStringAsync("invalidNumber");

var validationMessages = rootComponent.EditContext.GetValidationMessages(fieldIdentifier);
Assert.Contains("The Number from attribute field is not valid.", validationMessages);
}

[Fact]
public async Task InputElementIsAssignedSuccessfully()
{
Expand Down Expand Up @@ -273,6 +292,7 @@ class TestModel

public Guid? NullableGuid { get; set; }

[Display(Name = "Number from attribute")]
public int NotNullableInt { get; set; }

public int? NullableInt { get; set; }
Expand Down
12 changes: 12 additions & 0 deletions src/Components/test/E2ETest/Tests/FormsTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,18 @@ public void DisplayNameReadsAttributesCorrectly()
Browser.Equal("Product Name", () => localizedLabel.Text);
}

[Fact]
public void InputNumberParsingErrorUsesDisplayNameAttribute()
{
var appElement = Browser.MountTestComponent<DisplayNameComponent>();
var priceInput = appElement.FindElement(By.Id("price-input"));
var messagesAccessor = CreateValidationMessagesAccessor(appElement);
priceInput.SendKeys(Keys.Control + "a");
priceInput.SendKeys(Keys.Delete);
priceInput.SendKeys(Keys.Tab);
Browser.Equal(new[] { "The Unit Price field must be a number." }, messagesAccessor);
}

[Fact]
public void InputComponentsCauseContainerToRerenderOnChange()
{
Expand Down
Loading
Loading