From ba210fdd9cab5c2d4497fef76155fae56149b67f Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Wed, 3 Dec 2025 15:50:59 +0100 Subject: [PATCH 01/14] API proposal. --- .../Web/src/Forms/DisplayNameLabel.cs | 79 +++++++++++++++++++ .../Web/src/PublicAPI.Unshipped.txt | 8 ++ 2 files changed, 87 insertions(+) create mode 100644 src/Components/Web/src/Forms/DisplayNameLabel.cs diff --git a/src/Components/Web/src/Forms/DisplayNameLabel.cs b/src/Components/Web/src/Forms/DisplayNameLabel.cs new file mode 100644 index 000000000000..59e52c297fe8 --- /dev/null +++ b/src/Components/Web/src/Forms/DisplayNameLabel.cs @@ -0,0 +1,79 @@ +// 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 System.Reflection; +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 DisplayNameLabel : ComponentBase +{ + private Expression>? _previousFieldAccessor; + private string? _displayName; + + /// + /// Gets or sets a collection of additional attributes that will be applied to the created element. + /// + [Parameter(CaptureUnmatchedValues = true)] + public IReadOnlyDictionary? AdditionalAttributes { get; set; } + + /// + /// Specifies the field for which the display name should be shown. + /// + [Parameter, EditorRequired] + public Expression>? For { get; set; } + + /// + protected override void OnParametersSet() + { + if (For == null) + { + throw new InvalidOperationException($"{GetType()} requires a value for the " + + $"{nameof(For)} parameter."); + } + + if (For != _previousFieldAccessor) + { + _displayName = GetDisplayName(For); + _previousFieldAccessor = For; + } + } + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.AddContent(0, _displayName); + } + + private static string GetDisplayName(Expression> expression) + { + if (expression.Body is MemberExpression memberExpression) + { + var member = memberExpression.Member; + + var displayAttribute = member.GetCustomAttribute(); + if (displayAttribute?.Name != null) + { + return displayAttribute.Name; + } + + var displayNameAttribute = member.GetCustomAttribute(); + if (displayNameAttribute?.DisplayName != null) + { + return displayNameAttribute.DisplayName; + } + + return member.Name; + } + + return string.Empty; + } +} diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 369f33715778..84b2e7fd2bad 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -40,3 +40,11 @@ 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.DisplayNameLabel +Microsoft.AspNetCore.Components.Forms.DisplayNameLabel.AdditionalAttributes.get -> System.Collections.Generic.IReadOnlyDictionary? +Microsoft.AspNetCore.Components.Forms.DisplayNameLabel.AdditionalAttributes.set -> void +Microsoft.AspNetCore.Components.Forms.DisplayNameLabel.DisplayNameLabel() -> void +Microsoft.AspNetCore.Components.Forms.DisplayNameLabel.For.get -> System.Linq.Expressions.Expression!>? +Microsoft.AspNetCore.Components.Forms.DisplayNameLabel.For.set -> void +override Microsoft.AspNetCore.Components.Forms.DisplayNameLabel.BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder! builder) -> void +override Microsoft.AspNetCore.Components.Forms.DisplayNameLabel.OnParametersSet() -> void From 46795d55ceddd9d6bae8c0766c5dc38c2683065a Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Wed, 3 Dec 2025 15:51:13 +0100 Subject: [PATCH 02/14] Unit tests. --- .../Web/test/Forms/DisplayNameLabelTest.cs | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 src/Components/Web/test/Forms/DisplayNameLabelTest.cs diff --git a/src/Components/Web/test/Forms/DisplayNameLabelTest.cs b/src/Components/Web/test/Forms/DisplayNameLabelTest.cs new file mode 100644 index 000000000000..d2d12aafee1c --- /dev/null +++ b/src/Components/Web/test/Forms/DisplayNameLabelTest.cs @@ -0,0 +1,206 @@ +// 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 DisplayNameLabelTest +{ + [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); + } + + 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 DisplayNameLabel or DisplayNameLabel or DisplayNameLabel); + + // 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; } + } +} From a16749751da5f9983ea4ca03689374536b80a0ea Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Wed, 3 Dec 2025 16:12:18 +0100 Subject: [PATCH 03/14] Add E2E tests. --- .../test/E2ETest/Tests/FormsTest.cs | 22 ++++++++++++++ .../FormsTest/DisplayNameLabelComponent.razor | 30 +++++++++++++++++++ .../test/testassets/BasicTestApp/Index.razor | 1 + 3 files changed, 53 insertions(+) create mode 100644 src/Components/test/testassets/BasicTestApp/FormsTest/DisplayNameLabelComponent.razor diff --git a/src/Components/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs index b58dee62cf0b..c921368b1c9c 100644 --- a/src/Components/test/E2ETest/Tests/FormsTest.cs +++ b/src/Components/test/E2ETest/Tests/FormsTest.cs @@ -555,6 +555,28 @@ public void ErrorsFromCompareAttribute() Browser.Empty(confirmEmailValidationMessage); } + [Fact] + public void DisplayNameLabelReadsAttributesCorrectly() + { + 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); + } + [Fact] public void InputComponentsCauseContainerToRerenderOnChange() { diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/DisplayNameLabelComponent.razor b/src/Components/test/testassets/BasicTestApp/FormsTest/DisplayNameLabelComponent.razor new file mode 100644 index 000000000000..0909755d0924 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/DisplayNameLabelComponent.razor @@ -0,0 +1,30 @@ +@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"; + } +} diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index f9cc718f7ca1..e38d57b7b6a5 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -52,6 +52,7 @@ + From 60aa7f7e134bd11000b2a44354fa36c10aba04f3 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Wed, 3 Dec 2025 16:27:22 +0100 Subject: [PATCH 04/14] Alphabetically. --- src/Components/test/testassets/BasicTestApp/Index.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index e38d57b7b6a5..6a31bdddf352 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -27,6 +27,7 @@ + @@ -52,7 +53,6 @@ - From db4d8afbf7a922957c9e180ecb0229534b3e64df Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Wed, 3 Dec 2025 16:55:58 +0100 Subject: [PATCH 05/14] Update templates to use the new feature. --- .../Account/Pages/ForgotPassword.razor | 5 ++++- .../Components/Account/Pages/Login.razor | 14 ++++++++++---- .../Account/Pages/Manage/ChangePassword.razor | 12 +++++++++--- .../Account/Pages/Manage/Email.razor | 4 +++- .../Components/Account/Pages/Register.razor | 12 +++++++++--- .../Account/Pages/ResetPassword.razor | 18 +++++++++++++----- 6 files changed, 48 insertions(+), 17 deletions(-) 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..941efa5da4b1 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..6a4377362420 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 @@
- +
- - - + + +
@code { @@ -26,5 +27,9 @@ // 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/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; +}