Skip to content

Commit 5bc2c49

Browse files
authored
Add DisplayName to inputs (#24029)
Add a `DisplayName` parameter to `InputBase`, which is used in validation messages instead of `FieldIdentifier.FieldName`. - This works for `InputDate`, `InputNumber` and `InputSelect`. - Extracted some shared code, just like what @StephanZahariev did in his PR. Addresses #11414
1 parent bbb5bb7 commit 5bc2c49

File tree

11 files changed

+253
-117
lines changed

11 files changed

+253
-117
lines changed

src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ protected InputBase() { }
6060
[System.Diagnostics.CodeAnalysis.AllowNullAttribute]
6161
protected TValue CurrentValue { get { throw null; } set { } }
6262
protected string? CurrentValueAsString { get { throw null; } set { } }
63+
[Microsoft.AspNetCore.Components.ParameterAttribute]
64+
public string? DisplayName { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
6365
protected Microsoft.AspNetCore.Components.Forms.EditContext EditContext { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
6466
protected internal Microsoft.AspNetCore.Components.Forms.FieldIdentifier FieldIdentifier { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
6567
[Microsoft.AspNetCore.Components.ParameterAttribute]

src/Components/Web/src/Forms/InputBase.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ public abstract class InputBase<TValue> : ComponentBase, IDisposable
5050
/// </summary>
5151
[Parameter] public Expression<Func<TValue>>? ValueExpression { get; set; }
5252

53+
/// <summary>
54+
/// Gets or sets the display name for this field.
55+
/// <para>This value is used when generating error messages when the input value fails to parse correctly.</para>
56+
/// </summary>
57+
[Parameter] public string? DisplayName { get; set; }
58+
5359
/// <summary>
5460
/// Gets the associated <see cref="Forms.EditContext"/>.
5561
/// </summary>

src/Components/Web/src/Forms/InputDate.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ protected override bool TryParseValueFromString(string? value, [MaybeNull] out T
7575
}
7676
else
7777
{
78-
validationErrorMessage = string.Format(ParsingErrorMessage, FieldIdentifier.FieldName);
78+
validationErrorMessage = string.Format(ParsingErrorMessage, DisplayName ?? FieldIdentifier.FieldName);
7979
return false;
8080
}
8181
}

src/Components/Web/src/Forms/InputExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public static bool TryParseSelectableValueFromString<TValue>(this InputBase<TVal
2222
else
2323
{
2424
result = default;
25-
validationErrorMessage = $"The {input.FieldIdentifier.FieldName} field is not valid.";
25+
validationErrorMessage = $"The {input.DisplayName ?? input.FieldIdentifier.FieldName} field is not valid.";
2626
return false;
2727
}
2828
}

src/Components/Web/src/Forms/InputNumber.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ protected override bool TryParseValueFromString(string? value, [MaybeNull] out T
6464
}
6565
else
6666
{
67-
validationErrorMessage = string.Format(ParsingErrorMessage, FieldIdentifier.FieldName);
67+
validationErrorMessage = string.Format(ParsingErrorMessage, DisplayName ?? FieldIdentifier.FieldName);
6868
return false;
6969
}
7070
}

src/Components/Web/test/Forms/InputBaseTest.cs

Lines changed: 15 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,7 @@
44
using System;
55
using System.Collections.Generic;
66
using System.Linq;
7-
using System.Linq.Expressions;
87
using System.Threading.Tasks;
9-
using Microsoft.AspNetCore.Components.Rendering;
10-
using Microsoft.AspNetCore.Components.RenderTree;
118
using Microsoft.AspNetCore.Components.Test.Helpers;
129
using Xunit;
1310

@@ -35,7 +32,7 @@ public async Task ThrowsIfEditContextChanges()
3532
// Arrange
3633
var model = new TestModel();
3734
var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>> { EditContext = new EditContext(model), ValueExpression = () => model.StringProperty };
38-
await RenderAndGetTestInputComponentAsync(rootComponent);
35+
await InputRenderer.RenderAndGetComponent(rootComponent);
3936

4037
// Act/Assert
4138
rootComponent.EditContext = new EditContext(model);
@@ -51,7 +48,7 @@ public async Task ThrowsIfNoValueExpressionIsSupplied()
5148
var rootComponent = new TestInputHostComponent<string, TestInputComponent<string>> { EditContext = new EditContext(model) };
5249

5350
// Act/Assert
54-
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => RenderAndGetTestInputComponentAsync(rootComponent));
51+
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => InputRenderer.RenderAndGetComponent(rootComponent));
5552
Assert.Contains($"{typeof(TestInputComponent<string>)} requires a value for the 'ValueExpression' parameter. Normally this is provided automatically when using 'bind-Value'.", ex.Message);
5653
}
5754

@@ -68,7 +65,7 @@ public async Task GetsCurrentValueFromValueParameter()
6865
};
6966

7067
// Act
71-
var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
68+
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
7269

7370
// Assert
7471
Assert.Equal("some value", inputComponent.CurrentValue);
@@ -87,7 +84,7 @@ public async Task ExposesEditContextToSubclass()
8784
};
8885

8986
// Act
90-
var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
87+
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
9188

9289
// Assert
9390
Assert.Same(rootComponent.EditContext, inputComponent.EditContext);
@@ -106,7 +103,7 @@ public async Task ExposesFieldIdentifierToSubclass()
106103
};
107104

108105
// Act
109-
var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
106+
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
110107

111108
// Assert
112109
Assert.Equal(FieldIdentifier.Create(() => model.StringProperty), inputComponent.FieldIdentifier);
@@ -123,7 +120,7 @@ public async Task CanReadBackChangesToCurrentValue()
123120
Value = "initial value",
124121
ValueExpression = () => model.StringProperty
125122
};
126-
var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
123+
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
127124
Assert.Equal("initial value", inputComponent.CurrentValue);
128125

129126
// Act
@@ -146,7 +143,7 @@ public async Task WritingToCurrentValueInvokesValueChangedIfDifferent()
146143
ValueChanged = val => valueChangedCallLog.Add(val),
147144
ValueExpression = () => model.StringProperty
148145
};
149-
var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
146+
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
150147
Assert.Empty(valueChangedCallLog);
151148

152149
// Act
@@ -169,7 +166,7 @@ public async Task WritingToCurrentValueDoesNotInvokeValueChangedIfUnchanged()
169166
ValueChanged = val => valueChangedCallLog.Add(val),
170167
ValueExpression = () => model.StringProperty
171168
};
172-
var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
169+
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
173170
Assert.Empty(valueChangedCallLog);
174171

175172
// Act
@@ -190,7 +187,7 @@ public async Task WritingToCurrentValueNotifiesEditContext()
190187
Value = "initial value",
191188
ValueExpression = () => model.StringProperty
192189
};
193-
var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
190+
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
194191
Assert.False(rootComponent.EditContext.IsModified(() => model.StringProperty));
195192

196193
// Act
@@ -213,7 +210,7 @@ public async Task SuppliesFieldClassCorrespondingToFieldState()
213210
var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty);
214211

215212
// Act/Assert: Initially, it's valid and unmodified
216-
var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
213+
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
217214
Assert.Equal("valid", inputComponent.CssClass); // no Class was specified
218215

219216
// Act/Assert: Modify the field
@@ -251,7 +248,7 @@ public async Task CssClassCombinesClassWithFieldClass()
251248
var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty);
252249

253250
// Act/Assert
254-
var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
251+
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
255252
Assert.Equal("my-class other-class valid", inputComponent.CssClass);
256253

257254
// Act/Assert: Retains custom class when changing field class
@@ -270,7 +267,7 @@ public async Task SuppliesCurrentValueAsStringWithFormatting()
270267
Value = new DateTime(1915, 3, 2),
271268
ValueExpression = () => model.DateProperty
272269
};
273-
var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
270+
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
274271

275272
// Act/Assert
276273
Assert.Equal("1915/03/02", inputComponent.CurrentValueAsString);
@@ -289,7 +286,7 @@ public async Task ParsesCurrentValueAsStringWhenChanged_Valid()
289286
ValueExpression = () => model.DateProperty
290287
};
291288
var fieldIdentifier = FieldIdentifier.Create(() => model.DateProperty);
292-
var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
289+
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
293290
var numValidationStateChanges = 0;
294291
rootComponent.EditContext.OnValidationStateChanged += (sender, eventArgs) => { numValidationStateChanges++; };
295292

@@ -319,7 +316,7 @@ public async Task ParsesCurrentValueAsStringWhenChanged_Invalid()
319316
ValueExpression = () => model.DateProperty
320317
};
321318
var fieldIdentifier = FieldIdentifier.Create(() => model.DateProperty);
322-
var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
319+
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
323320
var numValidationStateChanges = 0;
324321
rootComponent.EditContext.OnValidationStateChanged += (sender, eventArgs) => { numValidationStateChanges++; };
325322

@@ -470,21 +467,6 @@ public async Task UserSpecifiedAriaValueIsNotChangedIfInvalid()
470467
Assert.Equal("userSpecifiedValue", component.AdditionalAttributes["aria-invalid"]);
471468
}
472469

473-
private static TComponent FindComponent<TComponent>(CapturedBatch batch)
474-
=> batch.ReferenceFrames
475-
.Where(f => f.FrameType == RenderTreeFrameType.Component)
476-
.Select(f => f.Component)
477-
.OfType<TComponent>()
478-
.Single();
479-
480-
private static async Task<TComponent> RenderAndGetTestInputComponentAsync<TValue, TComponent>(TestInputHostComponent<TValue, TComponent> hostComponent) where TComponent : TestInputComponent<TValue>
481-
{
482-
var testRenderer = new TestRenderer();
483-
var componentId = testRenderer.AssignRootComponentId(hostComponent);
484-
await testRenderer.RenderRootComponentAsync(componentId);
485-
return FindComponent<TComponent>(testRenderer.Batches.Single());
486-
}
487-
488470
class TestModel
489471
{
490472
public string StringProperty { get; set; }
@@ -530,7 +512,7 @@ public async Task SetCurrentValueAsStringAsync(string value)
530512
}
531513
}
532514

533-
class TestDateInputComponent : TestInputComponent<DateTime>
515+
private class TestDateInputComponent : TestInputComponent<DateTime>
534516
{
535517
protected override string FormatValueAsString(DateTime value)
536518
=> value.ToString("yyyy/MM/dd");
@@ -549,35 +531,5 @@ protected override bool TryParseValueFromString(string value, out DateTime resul
549531
}
550532
}
551533
}
552-
553-
class TestInputHostComponent<TValue, TComponent> : AutoRenderComponent where TComponent : TestInputComponent<TValue>
554-
{
555-
public Dictionary<string, object> AdditionalAttributes { get; set; }
556-
557-
public EditContext EditContext { get; set; }
558-
559-
public TValue Value { get; set; }
560-
561-
public Action<TValue> ValueChanged { get; set; }
562-
563-
public Expression<Func<TValue>> ValueExpression { get; set; }
564-
565-
protected override void BuildRenderTree(RenderTreeBuilder builder)
566-
{
567-
builder.OpenComponent<CascadingValue<EditContext>>(0);
568-
builder.AddAttribute(1, "Value", EditContext);
569-
builder.AddAttribute(2, "ChildContent", new RenderFragment(childBuilder =>
570-
{
571-
childBuilder.OpenComponent<TComponent>(0);
572-
childBuilder.AddAttribute(0, "Value", Value);
573-
childBuilder.AddAttribute(1, "ValueChanged",
574-
EventCallback.Factory.Create(this, ValueChanged));
575-
childBuilder.AddAttribute(2, "ValueExpression", ValueExpression);
576-
childBuilder.AddMultipleAttributes(3, AdditionalAttributes);
577-
childBuilder.CloseComponent();
578-
}));
579-
builder.CloseComponent();
580-
}
581-
}
582534
}
583535
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Threading.Tasks;
7+
using Xunit;
8+
9+
namespace Microsoft.AspNetCore.Components.Forms
10+
{
11+
public class InputDateTest
12+
{
13+
[Fact]
14+
public async Task ValidationErrorUsesDisplayAttributeName()
15+
{
16+
// Arrange
17+
var model = new TestModel();
18+
var rootComponent = new TestInputHostComponent<DateTime, TestInputDateComponent>
19+
{
20+
EditContext = new EditContext(model),
21+
ValueExpression = () => model.DateProperty,
22+
AdditionalAttributes = new Dictionary<string, object>
23+
{
24+
{ "DisplayName", "Date property" }
25+
}
26+
};
27+
var fieldIdentifier = FieldIdentifier.Create(() => model.DateProperty);
28+
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
29+
30+
// Act
31+
await inputComponent.SetCurrentValueAsStringAsync("invalidDate");
32+
33+
// Assert
34+
var validationMessages = rootComponent.EditContext.GetValidationMessages(fieldIdentifier);
35+
Assert.NotEmpty(validationMessages);
36+
Assert.Contains("The Date property field must be a date.", validationMessages);
37+
}
38+
39+
private class TestModel
40+
{
41+
public DateTime DateProperty { get; set; }
42+
}
43+
44+
private class TestInputDateComponent : InputDate<DateTime>
45+
{
46+
public async Task SetCurrentValueAsStringAsync(string value)
47+
{
48+
// This is equivalent to the subclass writing to CurrentValueAsString
49+
// (e.g., from @bind), except to simplify the test code there's an InvokeAsync
50+
// here. In production code it wouldn't normally be required because @bind
51+
// calls run on the sync context anyway.
52+
await InvokeAsync(() => { base.CurrentValueAsString = value; });
53+
}
54+
}
55+
}
56+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System.Collections.Generic;
5+
using System.Threading.Tasks;
6+
using Xunit;
7+
8+
namespace Microsoft.AspNetCore.Components.Forms
9+
{
10+
public class InputNumberTest
11+
{
12+
[Fact]
13+
public async Task ValidationErrorUsesDisplayAttributeName()
14+
{
15+
// Arrange
16+
var model = new TestModel();
17+
var rootComponent = new TestInputHostComponent<int, TestInputNumberComponent>
18+
{
19+
EditContext = new EditContext(model),
20+
ValueExpression = () => model.SomeNumber,
21+
AdditionalAttributes = new Dictionary<string, object>
22+
{
23+
{ "DisplayName", "Some number" }
24+
}
25+
};
26+
var fieldIdentifier = FieldIdentifier.Create(() => model.SomeNumber);
27+
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
28+
29+
// Act
30+
await inputComponent.SetCurrentValueAsStringAsync("notANumber");
31+
32+
// Assert
33+
var validationMessages = rootComponent.EditContext.GetValidationMessages(fieldIdentifier);
34+
Assert.NotEmpty(validationMessages);
35+
Assert.Contains("The Some number field must be a number.", validationMessages);
36+
}
37+
38+
private class TestModel
39+
{
40+
public int SomeNumber { get; set; }
41+
}
42+
43+
private class TestInputNumberComponent : InputNumber<int>
44+
{
45+
public async Task SetCurrentValueAsStringAsync(string value)
46+
{
47+
// This is equivalent to the subclass writing to CurrentValueAsString
48+
// (e.g., from @bind), except to simplify the test code there's an InvokeAsync
49+
// here. In production code it wouldn't normally be required because @bind
50+
// calls run on the sync context anyway.
51+
await InvokeAsync(() => { base.CurrentValueAsString = value; });
52+
}
53+
}
54+
}
55+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System.Linq;
5+
using System.Threading.Tasks;
6+
using Microsoft.AspNetCore.Components.RenderTree;
7+
using Microsoft.AspNetCore.Components.Test.Helpers;
8+
9+
namespace Microsoft.AspNetCore.Components.Forms
10+
{
11+
internal static class InputRenderer
12+
{
13+
public static async Task<TComponent> RenderAndGetComponent<TValue, TComponent>(TestInputHostComponent<TValue, TComponent> hostComponent)
14+
where TComponent : InputBase<TValue>
15+
{
16+
var testRenderer = new TestRenderer();
17+
var componentId = testRenderer.AssignRootComponentId(hostComponent);
18+
await testRenderer.RenderRootComponentAsync(componentId);
19+
return FindComponent<TComponent>(testRenderer.Batches.Single());
20+
}
21+
22+
private static TComponent FindComponent<TComponent>(CapturedBatch batch)
23+
=> batch.ReferenceFrames
24+
.Where(f => f.FrameType == RenderTreeFrameType.Component)
25+
.Select(f => f.Component)
26+
.OfType<TComponent>()
27+
.Single();
28+
}
29+
}

0 commit comments

Comments
 (0)