diff --git a/src/Components/Web/src/Forms/ExpressionMemberAccessor.cs b/src/Components/Web/src/Forms/ExpressionMemberAccessor.cs index 8fbe3562a2d8..ff5eab76e673 100644 --- a/src/Components/Web/src/Forms/ExpressionMemberAccessor.cs +++ b/src/Components/Web/src/Forms/ExpressionMemberAccessor.cs @@ -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; @@ -31,26 +32,37 @@ private static MemberInfo GetMemberInfo(Expression> 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); @@ -84,6 +96,23 @@ public static string GetDisplayName(Expression> accessor) return GetDisplayName(member); } + public static bool TryGetDisplayName( + Expression> 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(); diff --git a/src/Components/Web/src/Forms/InputBase.cs b/src/Components/Web/src/Forms/InputBase.cs index cf1bb261e6fe..bc1e815e62f4 100644 --- a/src/Components/Web/src/Forms/InputBase.cs +++ b/src/Components/Web/src/Forms/InputBase.cs @@ -60,6 +60,18 @@ public abstract class InputBase : ComponentBase, IDisposable /// [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; + } + /// /// Gets the associated . /// This property is uninitialized if the input does not have a parent . diff --git a/src/Components/Web/src/Forms/InputDate.cs b/src/Components/Web/src/Forms/InputDate.cs index 3a7b1ba960ca..f00d425da118 100644 --- a/src/Components/Web/src/Forms/InputDate.cs +++ b/src/Components/Web/src/Forms/InputDate.cs @@ -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()); return false; } } diff --git a/src/Components/Web/src/Forms/InputExtensions.cs b/src/Components/Web/src/Forms/InputExtensions.cs index 2d88d7772e20..7ca51fd811f1 100644 --- a/src/Components/Web/src/Forms/InputExtensions.cs +++ b/src/Components/Web/src/Forms/InputExtensions.cs @@ -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."; return false; } catch (InvalidOperationException ex) diff --git a/src/Components/Web/src/Forms/InputNumber.cs b/src/Components/Web/src/Forms/InputNumber.cs index c2e9a1ebef50..b69c3ee3d87d 100644 --- a/src/Components/Web/src/Forms/InputNumber.cs +++ b/src/Components/Web/src/Forms/InputNumber.cs @@ -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; } } diff --git a/src/Components/Web/test/Forms/InputDateTest.cs b/src/Components/Web/test/Forms/InputDateTest.cs index 4192bf1f218b..6f9fffe4223b 100644 --- a/src/Components/Web/test/Forms/InputDateTest.cs +++ b/src/Components/Web/test/Forms/InputDateTest.cs @@ -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; @@ -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(); @@ -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 + { + 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() { @@ -99,6 +118,7 @@ private async Task RenderAndGetInputDateComponentIdAsync(TestInputHostCompo private class TestModel { + [Display(Name = "Date from attribute")] public DateTime DateProperty { get; set; } } diff --git a/src/Components/Web/test/Forms/InputNumberTest.cs b/src/Components/Web/test/Forms/InputNumberTest.cs index dc4f76ce4acc..55e1d23ad27f 100644 --- a/src/Components/Web/test/Forms/InputNumberTest.cs +++ b/src/Components/Web/test/Forms/InputNumberTest.cs @@ -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; @@ -21,20 +24,20 @@ public InputNumberTest() } [Fact] - public async Task ValidationErrorUsesDisplayAttributeName() + public async Task ValidationErrorUsesExplicitDisplayName() { // Arrange var model = new TestModel(); var rootComponent = new TestInputHostComponent { EditContext = new EditContext(model), - ValueExpression = () => model.SomeNumber, + ValueExpression = () => model.NumberWithDisplayAttribute, AdditionalAttributes = new Dictionary { { "DisplayName", "Some number" } } }; - var fieldIdentifier = FieldIdentifier.Create(() => model.SomeNumber); + var fieldIdentifier = FieldIdentifier.Create(() => model.NumberWithDisplayAttribute); var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent); // Act @@ -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() { @@ -128,6 +151,24 @@ public async Task ExplicitIdOverridesGenerated() Assert.Equal("custom-number-id", idAttribute.AttributeValue); } + private static async Task AssertValidationErrorUsesDisplayName( + Expression> valueExpression, + string expectedValidationMessage) + { + var fieldIdentifier = FieldIdentifier.Create(valueExpression); + var rootComponent = new TestInputHostComponent + { + 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 RenderAndGetTestInputNumberComponentIdAsync(TestInputHostComponent hostComponent) { var hostComponentId = _testRenderer.AssignRootComponentId(hostComponent); @@ -139,6 +180,12 @@ private async Task 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 diff --git a/src/Components/Web/test/Forms/InputRadioTest.cs b/src/Components/Web/test/Forms/InputRadioTest.cs index 483b80697986..c72284718217 100644 --- a/src/Components/Web/test/Forms/InputRadioTest.cs +++ b/src/Components/Web/test/Forms/InputRadioTest.cs @@ -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; @@ -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 + { + 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))) @@ -125,9 +144,18 @@ private enum TestEnum private class TestModel { + [Display(Name = "Radio choice")] public TestEnum TestEnum { get; set; } } + private class TestInputRadioGroup : InputRadioGroup + { + public async Task SetCurrentValueAsStringAsync(string value) + { + await InvokeAsync(() => { base.CurrentValueAsString = value; }); + } + } + private class TestInputRadio : InputRadio { public string GroupName => Context.GroupName; diff --git a/src/Components/Web/test/Forms/InputSelectTest.cs b/src/Components/Web/test/Forms/InputSelectTest.cs index a2d6d0112138..7d4dc5374804 100644 --- a/src/Components/Web/test/Forms/InputSelectTest.cs +++ b/src/Components/Web/test/Forms/InputSelectTest.cs @@ -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; @@ -169,7 +170,7 @@ public async Task ParsesCurrentValueWhenUsingNullableInt() } [Fact] - public async Task ValidationErrorUsesDisplayAttributeName() + public async Task ValidationErrorUsesExplicitDisplayName() { // Arrange var model = new TestModel(); @@ -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> + { + 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() { @@ -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; } diff --git a/src/Components/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs index 21a982e107c1..d8d02890c6d5 100644 --- a/src/Components/test/E2ETest/Tests/FormsTest.cs +++ b/src/Components/test/E2ETest/Tests/FormsTest.cs @@ -581,6 +581,18 @@ public void DisplayNameReadsAttributesCorrectly() Browser.Equal("Product Name", () => localizedLabel.Text); } + [Fact] + public void InputNumberParsingErrorUsesDisplayNameAttribute() + { + var appElement = Browser.MountTestComponent(); + 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() { diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/DisplayNameComponent.razor b/src/Components/test/testassets/BasicTestApp/FormsTest/DisplayNameComponent.razor index fda324c7c8a9..4db681409948 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/DisplayNameComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/DisplayNameComponent.razor @@ -10,6 +10,11 @@

+ + + + + @code { private Product _product = new Product();