diff --git a/src/Components/Web/src/Forms/DisplayName.cs b/src/Components/Web/src/Forms/DisplayName.cs new file mode 100644 index 000000000000..964c71e2a67b --- /dev/null +++ b/src/Components/Web/src/Forms/DisplayName.cs @@ -0,0 +1,109 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Linq.Expressions; +using System.Reflection; +using Microsoft.AspNetCore.Components.HotReload; +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components.Forms; + +/// +/// Displays the display name for a specified field, reading from +/// or if present, or falling back to the property name. +/// +/// The type of the field. +public class DisplayName : IComponent +{ + private static readonly ConcurrentDictionary _displayNameCache = new(); + + private RenderHandle _renderHandle; + private Expression>? _previousFieldAccessor; + private string? _displayName; + + /// + /// Specifies the field for which the display name should be shown. + /// + [Parameter, EditorRequired] + public Expression>? For { get; set; } + + static DisplayName() + { + if (HotReloadManager.Default.MetadataUpdateSupported) + { + HotReloadManager.Default.OnDeltaApplied += ClearCache; + } + } + + /// + void IComponent.Attach(RenderHandle renderHandle) + { + _renderHandle = renderHandle; + } + + /// + Task IComponent.SetParametersAsync(ParameterView parameters) + { + parameters.SetParameterProperties(this); + + if (For is null) + { + throw new InvalidOperationException($"{GetType()} requires a value for the " + + $"{nameof(For)} parameter."); + } + + // Only recalculate if the expression changed + if (For != _previousFieldAccessor) + { + var member = ExpressionMemberAccessor.GetMemberInfo(For); + var newDisplayName = GetDisplayName(member); + + if (newDisplayName != _displayName) + { + _displayName = newDisplayName; + _renderHandle.Render(BuildRenderTree); + } + + _previousFieldAccessor = For; + } + + return Task.CompletedTask; + } + + private void BuildRenderTree(RenderTreeBuilder builder) + { + builder.AddContent(0, _displayName); + } + + private static string GetDisplayName(MemberInfo member) + { + return _displayNameCache.GetOrAdd(member, static m => + { + var displayAttribute = m.GetCustomAttribute(); + if (displayAttribute is not null) + { + var name = displayAttribute.GetName(); + if (name is not null) + { + return name; + } + } + + var displayNameAttribute = m.GetCustomAttribute(); + if (displayNameAttribute?.DisplayName is not null) + { + return displayNameAttribute.DisplayName; + } + + return m.Name; + }); + } + + private static void ClearCache() + { + _displayNameCache.Clear(); + } +} diff --git a/src/Components/Web/src/Forms/ExpressionMemberAccessor.cs b/src/Components/Web/src/Forms/ExpressionMemberAccessor.cs new file mode 100644 index 000000000000..89ee34d7582d --- /dev/null +++ b/src/Components/Web/src/Forms/ExpressionMemberAccessor.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Linq.Expressions; +using System.Reflection; +using Microsoft.AspNetCore.Components.HotReload; + +namespace Microsoft.AspNetCore.Components.Forms; + +internal static class ExpressionMemberAccessor +{ + private static readonly ConcurrentDictionary _memberInfoCache = new(); + + static ExpressionMemberAccessor() + { + if (HotReloadManager.Default.MetadataUpdateSupported) + { + HotReloadManager.Default.OnDeltaApplied += ClearCache; + } + } + + public static MemberInfo GetMemberInfo(Expression> accessor) + { + ArgumentNullException.ThrowIfNull(accessor); + + 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) + { + 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; + }); + } + + private static void ClearCache() + { + _memberInfoCache.Clear(); + } +} diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 369f33715778..9d7bb1b72c24 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -40,3 +40,7 @@ Microsoft.AspNetCore.Components.Web.Media.MediaSource.MediaSource(byte[]! data, Microsoft.AspNetCore.Components.Web.Media.MediaSource.MediaSource(System.IO.Stream! stream, string! mimeType, string! cacheKey) -> void Microsoft.AspNetCore.Components.Web.Media.MediaSource.MimeType.get -> string! Microsoft.AspNetCore.Components.Web.Media.MediaSource.Stream.get -> System.IO.Stream! +Microsoft.AspNetCore.Components.Forms.DisplayName +Microsoft.AspNetCore.Components.Forms.DisplayName.DisplayName() -> void +Microsoft.AspNetCore.Components.Forms.DisplayName.For.get -> System.Linq.Expressions.Expression!>? +Microsoft.AspNetCore.Components.Forms.DisplayName.For.set -> void diff --git a/src/Components/Web/test/Forms/DisplayNameTest.cs b/src/Components/Web/test/Forms/DisplayNameTest.cs new file mode 100644 index 000000000000..f7835c540827 --- /dev/null +++ b/src/Components/Web/test/Forms/DisplayNameTest.cs @@ -0,0 +1,232 @@ +// 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 Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.Test.Helpers; + +namespace Microsoft.AspNetCore.Components.Forms; + +public class DisplayNameTest +{ + [Fact] + public async Task ThrowsIfNoForParameterProvided() + { + // Arrange + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.CloseComponent(); + } + }; + + var testRenderer = new TestRenderer(); + var componentId = testRenderer.AssignRootComponentId(rootComponent); + + // Act & Assert + var ex = await Assert.ThrowsAsync( + async () => await testRenderer.RenderRootComponentAsync(componentId)); + Assert.Contains("For", ex.Message); + Assert.Contains("parameter", ex.Message); + } + + [Fact] + public async Task DisplaysPropertyNameWhenNoAttributePresent() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PlainProperty)); + builder.CloseComponent(); + } + }; + + // Act + var output = await RenderAndGetOutput(rootComponent); + + // Assert + Assert.Equal("PlainProperty", output); + } + + [Fact] + public async Task DisplaysDisplayAttributeName() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PropertyWithDisplayAttribute)); + builder.CloseComponent(); + } + }; + + // Act + var output = await RenderAndGetOutput(rootComponent); + + // Assert + Assert.Equal("Custom Display Name", output); + } + + [Fact] + public async Task DisplaysDisplayNameAttributeName() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PropertyWithDisplayNameAttribute)); + builder.CloseComponent(); + } + }; + + // Act + var output = await RenderAndGetOutput(rootComponent); + + // Assert + Assert.Equal("Custom DisplayName", output); + } + + [Fact] + public async Task DisplayAttributeTakesPrecedenceOverDisplayNameAttribute() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PropertyWithBothAttributes)); + builder.CloseComponent(); + } + }; + + // Act + var output = await RenderAndGetOutput(rootComponent); + + // Assert + // DisplayAttribute should take precedence per MVC conventions + Assert.Equal("Display Takes Precedence", output); + } + + [Fact] + public async Task WorksWithDifferentPropertyTypes() + { + // Arrange + var model = new TestModel(); + var intComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.IntProperty)); + builder.CloseComponent(); + } + }; + var dateComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.DateProperty)); + builder.CloseComponent(); + } + }; + + // Act + var intOutput = await RenderAndGetOutput(intComponent); + var dateOutput = await RenderAndGetOutput(dateComponent); + + // Assert + Assert.Equal("Integer Value", intOutput); + Assert.Equal("Date Value", dateOutput); + } + + [Fact] + public async Task SupportsLocalizationWithResourceType() + { + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PropertyWithResourceBasedDisplay)); + builder.CloseComponent(); + } + }; + + var output = await RenderAndGetOutput(rootComponent); + Assert.Equal("Localized Display Name", output); + } + + private static async Task RenderAndGetOutput(TestHostComponent rootComponent) + { + var testRenderer = new TestRenderer(); + var componentId = testRenderer.AssignRootComponentId(rootComponent); + await testRenderer.RenderRootComponentAsync(componentId); + + var batch = testRenderer.Batches.Single(); + var displayLabelComponentFrame = batch.ReferenceFrames + .First(f => f.FrameType == RenderTree.RenderTreeFrameType.Component && + f.Component is DisplayName or DisplayName or DisplayName); + + // Find the text content frame within the component + var textFrame = batch.ReferenceFrames + .First(f => f.FrameType == RenderTree.RenderTreeFrameType.Text); + + return textFrame.TextContent; + } + + private class TestHostComponent : ComponentBase + { + public RenderFragment InnerContent { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + InnerContent(builder); + } + } + + private class TestModel + { + public string PlainProperty { get; set; } = string.Empty; + + [Display(Name = "Custom Display Name")] + public string PropertyWithDisplayAttribute { get; set; } = string.Empty; + + [DisplayName("Custom DisplayName")] + public string PropertyWithDisplayNameAttribute { get; set; } = string.Empty; + + [Display(Name = "Display Takes Precedence")] + [DisplayName("This Should Not Be Used")] + public string PropertyWithBothAttributes { get; set; } = string.Empty; + + [Display(Name = "Integer Value")] + public int IntProperty { get; set; } + + [Display(Name = "Date Value")] + public DateTime DateProperty { get; set; } + + [Display(Name = nameof(TestResources.LocalizedDisplayName), ResourceType = typeof(TestResources))] + public string PropertyWithResourceBasedDisplay { get; set; } = string.Empty; + } + + public static class TestResources + { + public static string LocalizedDisplayName => "Localized Display Name"; + } +} diff --git a/src/Components/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs index b58dee62cf0b..304ea1721abc 100644 --- a/src/Components/test/E2ETest/Tests/FormsTest.cs +++ b/src/Components/test/E2ETest/Tests/FormsTest.cs @@ -555,6 +555,32 @@ public void ErrorsFromCompareAttribute() Browser.Empty(confirmEmailValidationMessage); } + [Fact] + public void DisplayNameReadsAttributesCorrectly() + { + var appElement = Browser.MountTestComponent(); + + // Check that DisplayAttribute.Name is displayed + var displayNameLabel = appElement.FindElement(By.Id("product-name-label")); + Browser.Equal("Product Name", () => displayNameLabel.Text); + + // Check that DisplayNameAttribute is displayed + var priceLabel = appElement.FindElement(By.Id("price-label")); + Browser.Equal("Unit Price", () => priceLabel.Text); + + // Check that DisplayAttribute takes precedence over DisplayNameAttribute + var stockLabel = appElement.FindElement(By.Id("stock-label")); + Browser.Equal("Stock Quantity", () => stockLabel.Text); + + // Check fallback to property name when no attributes present + var descriptionLabel = appElement.FindElement(By.Id("description-label")); + Browser.Equal("Description", () => descriptionLabel.Text); + + // Check that ResourceType localization works with English resources + var localizedLabel = appElement.FindElement(By.Id("localized-label")); + Browser.Equal("Product Name", () => localizedLabel.Text); + } + [Fact] public void InputComponentsCauseContainerToRerenderOnChange() { diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/DisplayNameComponent.razor b/src/Components/test/testassets/BasicTestApp/FormsTest/DisplayNameComponent.razor new file mode 100644 index 000000000000..fda324c7c8a9 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/DisplayNameComponent.razor @@ -0,0 +1,35 @@ +@using System.ComponentModel +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms + +
+

+

+

+

+

+
+ +@code { + private Product _product = new Product(); + + class Product + { + [Display(Name = "Product Name")] + public string Name { get; set; } = "Sample"; + + [DisplayName("Unit Price")] + public decimal Price { get; set; } = 99.99m; + + [Display(Name = "Stock Quantity")] + [DisplayName("Stock Amount")] // This should be ignored, Display takes precedence + public int StockQuantity { get; set; } = 100; + + // No attributes - should fall back to property name + public string Description { get; set; } = "Test"; + + // Uses resource file for localization - will show "Product Name" in en-US, "Nom du produit" in fr-FR + [Display(Name = nameof(TestResources.ProductName), ResourceType = typeof(TestResources))] + public string LocalizedName { get; set; } = "Localized"; + } +} diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index f9cc718f7ca1..ec6bb63c489d 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -27,6 +27,7 @@ + diff --git a/src/Components/test/testassets/BasicTestApp/Resources.fr.resx b/src/Components/test/testassets/BasicTestApp/Resources.fr.resx index 1371dedf99d7..5cffee6d040d 100644 --- a/src/Components/test/testassets/BasicTestApp/Resources.fr.resx +++ b/src/Components/test/testassets/BasicTestApp/Resources.fr.resx @@ -120,4 +120,7 @@ Bonjour! + + Nom du produit + \ No newline at end of file diff --git a/src/Components/test/testassets/BasicTestApp/Resources.resx b/src/Components/test/testassets/BasicTestApp/Resources.resx index f160f449685f..4233071d8a4b 100644 --- a/src/Components/test/testassets/BasicTestApp/Resources.resx +++ b/src/Components/test/testassets/BasicTestApp/Resources.resx @@ -120,4 +120,7 @@ Hello! + + Product Name + \ No newline at end of file diff --git a/src/Components/test/testassets/BasicTestApp/TestResources.cs b/src/Components/test/testassets/BasicTestApp/TestResources.cs new file mode 100644 index 000000000000..70f1bb537aaf --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/TestResources.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace BasicTestApp; + +// Wrap resources to make them available as public properties for [Display]. That attribute does not support +// internal properties. +public static class TestResources +{ + public static string ProductName => Resources.ProductName; +} diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/ForgotPassword.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/ForgotPassword.razor index ae82692ab7e9..5176ea466380 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/ForgotPassword.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/ForgotPassword.razor @@ -25,7 +25,9 @@
- +
@@ -69,6 +71,7 @@ { [Required] [EmailAddress] + [Display(Name = "Email")] public string Email { get; set; } = ""; } } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/Login.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/Login.razor index 65347e0afdb6..65157408b009 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/Login.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/Pages/Login.razor @@ -25,13 +25,17 @@
- +
- - - + + +