Skip to content

Commit 8752557

Browse files
ilonatommyCopilotjaviercn
authored
Blazor supports DisplayName for models (#64636)
* API proposal. * Unit tests. * Add E2E tests. * Update templates to use the new feature. Co-authored-by: Copilot <[email protected]> * Feedback: use same caching approach as `FieldIdentifier` uses. * Feedback: use the infrastructure from `ValidationMessage`. * Feedback: Make sure localization is supported. * Feedback: helper class can handle cache. Co-authored-by: Javier Calvarro Nelson <[email protected]> --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: Javier Calvarro Nelson <[email protected]>
1 parent 27d0e0a commit 8752557

File tree

16 files changed

+520
-17
lines changed

16 files changed

+520
-17
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.ComponentModel;
5+
using System.ComponentModel.DataAnnotations;
6+
using System.Linq.Expressions;
7+
using Microsoft.AspNetCore.Components.Rendering;
8+
9+
namespace Microsoft.AspNetCore.Components.Forms;
10+
11+
/// <summary>
12+
/// Displays the display name for a specified field, reading from <see cref="DisplayAttribute"/>
13+
/// or <see cref="DisplayNameAttribute"/> if present, or falling back to the property name.
14+
/// </summary>
15+
/// <typeparam name="TValue">The type of the field.</typeparam>
16+
public class DisplayName<TValue> : IComponent
17+
{
18+
19+
private RenderHandle _renderHandle;
20+
private Expression<Func<TValue>>? _previousFieldAccessor;
21+
private string? _displayName;
22+
23+
/// <summary>
24+
/// Specifies the field for which the display name should be shown.
25+
/// </summary>
26+
[Parameter, EditorRequired]
27+
public Expression<Func<TValue>>? For { get; set; }
28+
/// <inheritdoc />
29+
void IComponent.Attach(RenderHandle renderHandle)
30+
{
31+
_renderHandle = renderHandle;
32+
}
33+
34+
/// <inheritdoc />
35+
Task IComponent.SetParametersAsync(ParameterView parameters)
36+
{
37+
parameters.SetParameterProperties(this);
38+
39+
if (For is null)
40+
{
41+
throw new InvalidOperationException($"{GetType()} requires a value for the " +
42+
$"{nameof(For)} parameter.");
43+
}
44+
45+
// Only recalculate if the expression changed
46+
if (For != _previousFieldAccessor)
47+
{
48+
var newDisplayName = ExpressionMemberAccessor.GetDisplayName(For);
49+
50+
if (newDisplayName != _displayName)
51+
{
52+
_displayName = newDisplayName;
53+
_renderHandle.Render(BuildRenderTree);
54+
}
55+
56+
_previousFieldAccessor = For;
57+
}
58+
59+
return Task.CompletedTask;
60+
}
61+
62+
private void BuildRenderTree(RenderTreeBuilder builder)
63+
{
64+
builder.AddContent(0, _displayName);
65+
}
66+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Collections.Concurrent;
5+
using System.ComponentModel;
6+
using System.ComponentModel.DataAnnotations;
7+
using System.Linq.Expressions;
8+
using System.Reflection;
9+
using Microsoft.AspNetCore.Components.HotReload;
10+
11+
namespace Microsoft.AspNetCore.Components.Forms;
12+
13+
internal static class ExpressionMemberAccessor
14+
{
15+
private static readonly ConcurrentDictionary<Expression, MemberInfo> _memberInfoCache = new();
16+
private static readonly ConcurrentDictionary<MemberInfo, string> _displayNameCache = new();
17+
18+
static ExpressionMemberAccessor()
19+
{
20+
if (HotReloadManager.Default.MetadataUpdateSupported)
21+
{
22+
HotReloadManager.Default.OnDeltaApplied += ClearCache;
23+
}
24+
}
25+
26+
private static MemberInfo GetMemberInfo<TValue>(Expression<Func<TValue>> accessor)
27+
{
28+
ArgumentNullException.ThrowIfNull(accessor);
29+
30+
return _memberInfoCache.GetOrAdd(accessor, static expr =>
31+
{
32+
var lambdaExpression = (LambdaExpression)expr;
33+
var accessorBody = lambdaExpression.Body;
34+
35+
if (accessorBody is UnaryExpression unaryExpression
36+
&& unaryExpression.NodeType == ExpressionType.Convert
37+
&& unaryExpression.Type == typeof(object))
38+
{
39+
accessorBody = unaryExpression.Operand;
40+
}
41+
42+
if (accessorBody is not MemberExpression memberExpression)
43+
{
44+
throw new ArgumentException(
45+
$"The provided expression contains a {accessorBody.GetType().Name} which is not supported. " +
46+
$"Only simple member accessors (fields, properties) of an object are supported.");
47+
}
48+
49+
return memberExpression.Member;
50+
});
51+
}
52+
53+
public static string GetDisplayName(MemberInfo member)
54+
{
55+
ArgumentNullException.ThrowIfNull(member);
56+
57+
return _displayNameCache.GetOrAdd(member, static m =>
58+
{
59+
var displayAttribute = m.GetCustomAttribute<DisplayAttribute>();
60+
if (displayAttribute is not null)
61+
{
62+
var name = displayAttribute.GetName();
63+
if (name is not null)
64+
{
65+
return name;
66+
}
67+
}
68+
69+
var displayNameAttribute = m.GetCustomAttribute<DisplayNameAttribute>();
70+
if (displayNameAttribute?.DisplayName is not null)
71+
{
72+
return displayNameAttribute.DisplayName;
73+
}
74+
75+
return m.Name;
76+
});
77+
}
78+
79+
public static string GetDisplayName<TValue>(Expression<Func<TValue>> accessor)
80+
{
81+
ArgumentNullException.ThrowIfNull(accessor);
82+
var member = GetMemberInfo(accessor);
83+
return GetDisplayName(member);
84+
}
85+
86+
private static void ClearCache()
87+
{
88+
_memberInfoCache.Clear();
89+
_displayNameCache.Clear();
90+
}
91+
}

src/Components/Web/src/PublicAPI.Unshipped.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,7 @@ Microsoft.AspNetCore.Components.Web.Media.MediaSource.MediaSource(byte[]! data,
7070
Microsoft.AspNetCore.Components.Web.Media.MediaSource.MediaSource(System.IO.Stream! stream, string! mimeType, string! cacheKey) -> void
7171
Microsoft.AspNetCore.Components.Web.Media.MediaSource.MimeType.get -> string!
7272
Microsoft.AspNetCore.Components.Web.Media.MediaSource.Stream.get -> System.IO.Stream!
73+
Microsoft.AspNetCore.Components.Forms.DisplayName<TValue>
74+
Microsoft.AspNetCore.Components.Forms.DisplayName<TValue>.DisplayName() -> void
75+
Microsoft.AspNetCore.Components.Forms.DisplayName<TValue>.For.get -> System.Linq.Expressions.Expression<System.Func<TValue>!>?
76+
Microsoft.AspNetCore.Components.Forms.DisplayName<TValue>.For.set -> void
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.ComponentModel;
5+
using System.ComponentModel.DataAnnotations;
6+
using Microsoft.AspNetCore.Components.Rendering;
7+
using Microsoft.AspNetCore.Components.Test.Helpers;
8+
9+
namespace Microsoft.AspNetCore.Components.Forms;
10+
11+
public class DisplayNameTest
12+
{
13+
[Fact]
14+
public async Task ThrowsIfNoForParameterProvided()
15+
{
16+
// Arrange
17+
var rootComponent = new TestHostComponent
18+
{
19+
InnerContent = builder =>
20+
{
21+
builder.OpenComponent<DisplayName<string>>(0);
22+
builder.CloseComponent();
23+
}
24+
};
25+
26+
var testRenderer = new TestRenderer();
27+
var componentId = testRenderer.AssignRootComponentId(rootComponent);
28+
29+
// Act & Assert
30+
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
31+
async () => await testRenderer.RenderRootComponentAsync(componentId));
32+
Assert.Contains("For", ex.Message);
33+
Assert.Contains("parameter", ex.Message);
34+
}
35+
36+
[Fact]
37+
public async Task DisplaysPropertyNameWhenNoAttributePresent()
38+
{
39+
// Arrange
40+
var model = new TestModel();
41+
var rootComponent = new TestHostComponent
42+
{
43+
InnerContent = builder =>
44+
{
45+
builder.OpenComponent<DisplayName<string>>(0);
46+
builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression<Func<string>>)(() => model.PlainProperty));
47+
builder.CloseComponent();
48+
}
49+
};
50+
51+
// Act
52+
var output = await RenderAndGetOutput(rootComponent);
53+
54+
// Assert
55+
Assert.Equal("PlainProperty", output);
56+
}
57+
58+
[Fact]
59+
public async Task DisplaysDisplayAttributeName()
60+
{
61+
// Arrange
62+
var model = new TestModel();
63+
var rootComponent = new TestHostComponent
64+
{
65+
InnerContent = builder =>
66+
{
67+
builder.OpenComponent<DisplayName<string>>(0);
68+
builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression<Func<string>>)(() => model.PropertyWithDisplayAttribute));
69+
builder.CloseComponent();
70+
}
71+
};
72+
73+
// Act
74+
var output = await RenderAndGetOutput(rootComponent);
75+
76+
// Assert
77+
Assert.Equal("Custom Display Name", output);
78+
}
79+
80+
[Fact]
81+
public async Task DisplaysDisplayNameAttributeName()
82+
{
83+
// Arrange
84+
var model = new TestModel();
85+
var rootComponent = new TestHostComponent
86+
{
87+
InnerContent = builder =>
88+
{
89+
builder.OpenComponent<DisplayName<string>>(0);
90+
builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression<Func<string>>)(() => model.PropertyWithDisplayNameAttribute));
91+
builder.CloseComponent();
92+
}
93+
};
94+
95+
// Act
96+
var output = await RenderAndGetOutput(rootComponent);
97+
98+
// Assert
99+
Assert.Equal("Custom DisplayName", output);
100+
}
101+
102+
[Fact]
103+
public async Task DisplayAttributeTakesPrecedenceOverDisplayNameAttribute()
104+
{
105+
// Arrange
106+
var model = new TestModel();
107+
var rootComponent = new TestHostComponent
108+
{
109+
InnerContent = builder =>
110+
{
111+
builder.OpenComponent<DisplayName<string>>(0);
112+
builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression<Func<string>>)(() => model.PropertyWithBothAttributes));
113+
builder.CloseComponent();
114+
}
115+
};
116+
117+
// Act
118+
var output = await RenderAndGetOutput(rootComponent);
119+
120+
// Assert
121+
// DisplayAttribute should take precedence per MVC conventions
122+
Assert.Equal("Display Takes Precedence", output);
123+
}
124+
125+
[Fact]
126+
public async Task WorksWithDifferentPropertyTypes()
127+
{
128+
// Arrange
129+
var model = new TestModel();
130+
var intComponent = new TestHostComponent
131+
{
132+
InnerContent = builder =>
133+
{
134+
builder.OpenComponent<DisplayName<int>>(0);
135+
builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression<Func<int>>)(() => model.IntProperty));
136+
builder.CloseComponent();
137+
}
138+
};
139+
var dateComponent = new TestHostComponent
140+
{
141+
InnerContent = builder =>
142+
{
143+
builder.OpenComponent<DisplayName<DateTime>>(0);
144+
builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression<Func<DateTime>>)(() => model.DateProperty));
145+
builder.CloseComponent();
146+
}
147+
};
148+
149+
// Act
150+
var intOutput = await RenderAndGetOutput(intComponent);
151+
var dateOutput = await RenderAndGetOutput(dateComponent);
152+
153+
// Assert
154+
Assert.Equal("Integer Value", intOutput);
155+
Assert.Equal("Date Value", dateOutput);
156+
}
157+
158+
[Fact]
159+
public async Task SupportsLocalizationWithResourceType()
160+
{
161+
var model = new TestModel();
162+
var rootComponent = new TestHostComponent
163+
{
164+
InnerContent = builder =>
165+
{
166+
builder.OpenComponent<DisplayName<string>>(0);
167+
builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression<Func<string>>)(() => model.PropertyWithResourceBasedDisplay));
168+
builder.CloseComponent();
169+
}
170+
};
171+
172+
var output = await RenderAndGetOutput(rootComponent);
173+
Assert.Equal("Localized Display Name", output);
174+
}
175+
176+
private static async Task<string> RenderAndGetOutput(TestHostComponent rootComponent)
177+
{
178+
var testRenderer = new TestRenderer();
179+
var componentId = testRenderer.AssignRootComponentId(rootComponent);
180+
await testRenderer.RenderRootComponentAsync(componentId);
181+
182+
var batch = testRenderer.Batches.Single();
183+
var displayLabelComponentFrame = batch.ReferenceFrames
184+
.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Component &&
185+
f.Component is DisplayName<string> or DisplayName<int> or DisplayName<DateTime>);
186+
187+
// Find the text content frame within the component
188+
var textFrame = batch.ReferenceFrames
189+
.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Text);
190+
191+
return textFrame.TextContent;
192+
}
193+
194+
private class TestHostComponent : ComponentBase
195+
{
196+
public RenderFragment InnerContent { get; set; }
197+
198+
protected override void BuildRenderTree(RenderTreeBuilder builder)
199+
{
200+
InnerContent(builder);
201+
}
202+
}
203+
204+
private class TestModel
205+
{
206+
public string PlainProperty { get; set; } = string.Empty;
207+
208+
[Display(Name = "Custom Display Name")]
209+
public string PropertyWithDisplayAttribute { get; set; } = string.Empty;
210+
211+
[DisplayName("Custom DisplayName")]
212+
public string PropertyWithDisplayNameAttribute { get; set; } = string.Empty;
213+
214+
[Display(Name = "Display Takes Precedence")]
215+
[DisplayName("This Should Not Be Used")]
216+
public string PropertyWithBothAttributes { get; set; } = string.Empty;
217+
218+
[Display(Name = "Integer Value")]
219+
public int IntProperty { get; set; }
220+
221+
[Display(Name = "Date Value")]
222+
public DateTime DateProperty { get; set; }
223+
224+
[Display(Name = nameof(TestResources.LocalizedDisplayName), ResourceType = typeof(TestResources))]
225+
public string PropertyWithResourceBasedDisplay { get; set; } = string.Empty;
226+
}
227+
228+
public static class TestResources
229+
{
230+
public static string LocalizedDisplayName => "Localized Display Name";
231+
}
232+
}

0 commit comments

Comments
 (0)