From 5bc2c49ed560663c57198df3c6e92273c319173b Mon Sep 17 00:00:00 2001 From: Haytam Zanid <34218324+zHaytam@users.noreply.github.com> Date: Thu, 16 Jul 2020 23:08:09 +0100 Subject: [PATCH] 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 --- ...ft.AspNetCore.Components.Web.netcoreapp.cs | 2 + src/Components/Web/src/Forms/InputBase.cs | 6 ++ src/Components/Web/src/Forms/InputDate.cs | 2 +- .../Web/src/Forms/InputExtensions.cs | 2 +- src/Components/Web/src/Forms/InputNumber.cs | 2 +- .../Web/test/Forms/InputBaseTest.cs | 78 +++------------ .../Web/test/Forms/InputDateTest.cs | 56 +++++++++++ .../Web/test/Forms/InputNumberTest.cs | 55 +++++++++++ .../Web/test/Forms/InputRenderer.cs | 29 ++++++ .../Web/test/Forms/InputSelectTest.cs | 97 +++++++++---------- .../Web/test/Forms/TestInputHostComponent.cs | 41 ++++++++ 11 files changed, 253 insertions(+), 117 deletions(-) create mode 100644 src/Components/Web/test/Forms/InputDateTest.cs create mode 100644 src/Components/Web/test/Forms/InputNumberTest.cs create mode 100644 src/Components/Web/test/Forms/InputRenderer.cs create mode 100644 src/Components/Web/test/Forms/TestInputHostComponent.cs diff --git a/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs b/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs index 795dbe6ea3..14ff4f7367 100644 --- a/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs +++ b/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs @@ -60,6 +60,8 @@ namespace Microsoft.AspNetCore.Components.Forms [System.Diagnostics.CodeAnalysis.AllowNullAttribute] protected TValue CurrentValue { get { throw null; } set { } } protected string? CurrentValueAsString { get { throw null; } set { } } + [Microsoft.AspNetCore.Components.ParameterAttribute] + public string? DisplayName { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } protected Microsoft.AspNetCore.Components.Forms.EditContext EditContext { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } protected internal Microsoft.AspNetCore.Components.Forms.FieldIdentifier FieldIdentifier { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } [Microsoft.AspNetCore.Components.ParameterAttribute] diff --git a/src/Components/Web/src/Forms/InputBase.cs b/src/Components/Web/src/Forms/InputBase.cs index 5a8fceecf8..08fd03c92a 100644 --- a/src/Components/Web/src/Forms/InputBase.cs +++ b/src/Components/Web/src/Forms/InputBase.cs @@ -50,6 +50,12 @@ namespace Microsoft.AspNetCore.Components.Forms /// [Parameter] public Expression>? ValueExpression { get; set; } + /// + /// Gets or sets the display name for this field. + /// This value is used when generating error messages when the input value fails to parse correctly. + /// + [Parameter] public string? DisplayName { get; set; } + /// /// Gets the associated . /// diff --git a/src/Components/Web/src/Forms/InputDate.cs b/src/Components/Web/src/Forms/InputDate.cs index e7132988b9..4372646c7b 100644 --- a/src/Components/Web/src/Forms/InputDate.cs +++ b/src/Components/Web/src/Forms/InputDate.cs @@ -75,7 +75,7 @@ namespace Microsoft.AspNetCore.Components.Forms } else { - validationErrorMessage = string.Format(ParsingErrorMessage, FieldIdentifier.FieldName); + validationErrorMessage = string.Format(ParsingErrorMessage, DisplayName ?? FieldIdentifier.FieldName); return false; } } diff --git a/src/Components/Web/src/Forms/InputExtensions.cs b/src/Components/Web/src/Forms/InputExtensions.cs index a1ace92141..748af5c78e 100644 --- a/src/Components/Web/src/Forms/InputExtensions.cs +++ b/src/Components/Web/src/Forms/InputExtensions.cs @@ -22,7 +22,7 @@ namespace Microsoft.AspNetCore.Components.Forms else { result = default; - validationErrorMessage = $"The {input.FieldIdentifier.FieldName} field is not valid."; + validationErrorMessage = $"The {input.DisplayName ?? input.FieldIdentifier.FieldName} field is not valid."; return false; } } diff --git a/src/Components/Web/src/Forms/InputNumber.cs b/src/Components/Web/src/Forms/InputNumber.cs index 515fc5ceac..51ac2c5241 100644 --- a/src/Components/Web/src/Forms/InputNumber.cs +++ b/src/Components/Web/src/Forms/InputNumber.cs @@ -64,7 +64,7 @@ namespace Microsoft.AspNetCore.Components.Forms } else { - validationErrorMessage = string.Format(ParsingErrorMessage, FieldIdentifier.FieldName); + validationErrorMessage = string.Format(ParsingErrorMessage, DisplayName ?? FieldIdentifier.FieldName); return false; } } diff --git a/src/Components/Web/test/Forms/InputBaseTest.cs b/src/Components/Web/test/Forms/InputBaseTest.cs index 26464b8386..6a8da5ca76 100644 --- a/src/Components/Web/test/Forms/InputBaseTest.cs +++ b/src/Components/Web/test/Forms/InputBaseTest.cs @@ -4,10 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Linq.Expressions; using System.Threading.Tasks; -using Microsoft.AspNetCore.Components.Rendering; -using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.Test.Helpers; using Xunit; @@ -35,7 +32,7 @@ namespace Microsoft.AspNetCore.Components.Forms // Arrange var model = new TestModel(); var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model), ValueExpression = () => model.StringProperty }; - await RenderAndGetTestInputComponentAsync(rootComponent); + await InputRenderer.RenderAndGetComponent(rootComponent); // Act/Assert rootComponent.EditContext = new EditContext(model); @@ -51,7 +48,7 @@ namespace Microsoft.AspNetCore.Components.Forms var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model) }; // Act/Assert - var ex = await Assert.ThrowsAsync(() => RenderAndGetTestInputComponentAsync(rootComponent)); + var ex = await Assert.ThrowsAsync(() => InputRenderer.RenderAndGetComponent(rootComponent)); Assert.Contains($"{typeof(TestInputComponent)} requires a value for the 'ValueExpression' parameter. Normally this is provided automatically when using 'bind-Value'.", ex.Message); } @@ -68,7 +65,7 @@ namespace Microsoft.AspNetCore.Components.Forms }; // Act - var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent); // Assert Assert.Equal("some value", inputComponent.CurrentValue); @@ -87,7 +84,7 @@ namespace Microsoft.AspNetCore.Components.Forms }; // Act - var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent); // Assert Assert.Same(rootComponent.EditContext, inputComponent.EditContext); @@ -106,7 +103,7 @@ namespace Microsoft.AspNetCore.Components.Forms }; // Act - var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent); // Assert Assert.Equal(FieldIdentifier.Create(() => model.StringProperty), inputComponent.FieldIdentifier); @@ -123,7 +120,7 @@ namespace Microsoft.AspNetCore.Components.Forms Value = "initial value", ValueExpression = () => model.StringProperty }; - var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent); Assert.Equal("initial value", inputComponent.CurrentValue); // Act @@ -146,7 +143,7 @@ namespace Microsoft.AspNetCore.Components.Forms ValueChanged = val => valueChangedCallLog.Add(val), ValueExpression = () => model.StringProperty }; - var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent); Assert.Empty(valueChangedCallLog); // Act @@ -169,7 +166,7 @@ namespace Microsoft.AspNetCore.Components.Forms ValueChanged = val => valueChangedCallLog.Add(val), ValueExpression = () => model.StringProperty }; - var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent); Assert.Empty(valueChangedCallLog); // Act @@ -190,7 +187,7 @@ namespace Microsoft.AspNetCore.Components.Forms Value = "initial value", ValueExpression = () => model.StringProperty }; - var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent); Assert.False(rootComponent.EditContext.IsModified(() => model.StringProperty)); // Act @@ -213,7 +210,7 @@ namespace Microsoft.AspNetCore.Components.Forms var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty); // Act/Assert: Initially, it's valid and unmodified - var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent); Assert.Equal("valid", inputComponent.CssClass); // no Class was specified // Act/Assert: Modify the field @@ -251,7 +248,7 @@ namespace Microsoft.AspNetCore.Components.Forms var fieldIdentifier = FieldIdentifier.Create(() => model.StringProperty); // Act/Assert - var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent); Assert.Equal("my-class other-class valid", inputComponent.CssClass); // Act/Assert: Retains custom class when changing field class @@ -270,7 +267,7 @@ namespace Microsoft.AspNetCore.Components.Forms Value = new DateTime(1915, 3, 2), ValueExpression = () => model.DateProperty }; - var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent); // Act/Assert Assert.Equal("1915/03/02", inputComponent.CurrentValueAsString); @@ -289,7 +286,7 @@ namespace Microsoft.AspNetCore.Components.Forms ValueExpression = () => model.DateProperty }; var fieldIdentifier = FieldIdentifier.Create(() => model.DateProperty); - var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent); var numValidationStateChanges = 0; rootComponent.EditContext.OnValidationStateChanged += (sender, eventArgs) => { numValidationStateChanges++; }; @@ -319,7 +316,7 @@ namespace Microsoft.AspNetCore.Components.Forms ValueExpression = () => model.DateProperty }; var fieldIdentifier = FieldIdentifier.Create(() => model.DateProperty); - var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent); var numValidationStateChanges = 0; rootComponent.EditContext.OnValidationStateChanged += (sender, eventArgs) => { numValidationStateChanges++; }; @@ -470,21 +467,6 @@ namespace Microsoft.AspNetCore.Components.Forms Assert.Equal("userSpecifiedValue", component.AdditionalAttributes["aria-invalid"]); } - private static TComponent FindComponent(CapturedBatch batch) - => batch.ReferenceFrames - .Where(f => f.FrameType == RenderTreeFrameType.Component) - .Select(f => f.Component) - .OfType() - .Single(); - - private static async Task RenderAndGetTestInputComponentAsync(TestInputHostComponent hostComponent) where TComponent : TestInputComponent - { - var testRenderer = new TestRenderer(); - var componentId = testRenderer.AssignRootComponentId(hostComponent); - await testRenderer.RenderRootComponentAsync(componentId); - return FindComponent(testRenderer.Batches.Single()); - } - class TestModel { public string StringProperty { get; set; } @@ -530,7 +512,7 @@ namespace Microsoft.AspNetCore.Components.Forms } } - class TestDateInputComponent : TestInputComponent + private class TestDateInputComponent : TestInputComponent { protected override string FormatValueAsString(DateTime value) => value.ToString("yyyy/MM/dd"); @@ -549,35 +531,5 @@ namespace Microsoft.AspNetCore.Components.Forms } } } - - class TestInputHostComponent : AutoRenderComponent where TComponent : TestInputComponent - { - public Dictionary AdditionalAttributes { get; set; } - - public EditContext EditContext { get; set; } - - public TValue Value { get; set; } - - public Action ValueChanged { get; set; } - - public Expression> ValueExpression { get; set; } - - protected override void BuildRenderTree(RenderTreeBuilder builder) - { - builder.OpenComponent>(0); - builder.AddAttribute(1, "Value", EditContext); - builder.AddAttribute(2, "ChildContent", new RenderFragment(childBuilder => - { - childBuilder.OpenComponent(0); - childBuilder.AddAttribute(0, "Value", Value); - childBuilder.AddAttribute(1, "ValueChanged", - EventCallback.Factory.Create(this, ValueChanged)); - childBuilder.AddAttribute(2, "ValueExpression", ValueExpression); - childBuilder.AddMultipleAttributes(3, AdditionalAttributes); - childBuilder.CloseComponent(); - })); - builder.CloseComponent(); - } - } } } diff --git a/src/Components/Web/test/Forms/InputDateTest.cs b/src/Components/Web/test/Forms/InputDateTest.cs new file mode 100644 index 0000000000..9c6f87b832 --- /dev/null +++ b/src/Components/Web/test/Forms/InputDateTest.cs @@ -0,0 +1,56 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AspNetCore.Components.Forms +{ + public class InputDateTest + { + [Fact] + public async Task ValidationErrorUsesDisplayAttributeName() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueExpression = () => model.DateProperty, + AdditionalAttributes = new Dictionary + { + { "DisplayName", "Date property" } + } + }; + var fieldIdentifier = FieldIdentifier.Create(() => model.DateProperty); + var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent); + + // Act + await inputComponent.SetCurrentValueAsStringAsync("invalidDate"); + + // Assert + var validationMessages = rootComponent.EditContext.GetValidationMessages(fieldIdentifier); + Assert.NotEmpty(validationMessages); + Assert.Contains("The Date property field must be a date.", validationMessages); + } + + private class TestModel + { + public DateTime DateProperty { get; set; } + } + + private class TestInputDateComponent : InputDate + { + public async Task SetCurrentValueAsStringAsync(string value) + { + // This is equivalent to the subclass writing to CurrentValueAsString + // (e.g., from @bind), except to simplify the test code there's an InvokeAsync + // here. In production code it wouldn't normally be required because @bind + // calls run on the sync context anyway. + await InvokeAsync(() => { base.CurrentValueAsString = value; }); + } + } + } +} diff --git a/src/Components/Web/test/Forms/InputNumberTest.cs b/src/Components/Web/test/Forms/InputNumberTest.cs new file mode 100644 index 0000000000..6916f0e06e --- /dev/null +++ b/src/Components/Web/test/Forms/InputNumberTest.cs @@ -0,0 +1,55 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AspNetCore.Components.Forms +{ + public class InputNumberTest + { + [Fact] + public async Task ValidationErrorUsesDisplayAttributeName() + { + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent + { + EditContext = new EditContext(model), + ValueExpression = () => model.SomeNumber, + AdditionalAttributes = new Dictionary + { + { "DisplayName", "Some number" } + } + }; + var fieldIdentifier = FieldIdentifier.Create(() => model.SomeNumber); + var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent); + + // Act + await inputComponent.SetCurrentValueAsStringAsync("notANumber"); + + // Assert + var validationMessages = rootComponent.EditContext.GetValidationMessages(fieldIdentifier); + Assert.NotEmpty(validationMessages); + Assert.Contains("The Some number field must be a number.", validationMessages); + } + + private class TestModel + { + public int SomeNumber { get; set; } + } + + private class TestInputNumberComponent : InputNumber + { + public async Task SetCurrentValueAsStringAsync(string value) + { + // This is equivalent to the subclass writing to CurrentValueAsString + // (e.g., from @bind), except to simplify the test code there's an InvokeAsync + // here. In production code it wouldn't normally be required because @bind + // calls run on the sync context anyway. + await InvokeAsync(() => { base.CurrentValueAsString = value; }); + } + } + } +} diff --git a/src/Components/Web/test/Forms/InputRenderer.cs b/src/Components/Web/test/Forms/InputRenderer.cs new file mode 100644 index 0000000000..3c83981915 --- /dev/null +++ b/src/Components/Web/test/Forms/InputRenderer.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Test.Helpers; + +namespace Microsoft.AspNetCore.Components.Forms +{ + internal static class InputRenderer + { + public static async Task RenderAndGetComponent(TestInputHostComponent hostComponent) + where TComponent : InputBase + { + var testRenderer = new TestRenderer(); + var componentId = testRenderer.AssignRootComponentId(hostComponent); + await testRenderer.RenderRootComponentAsync(componentId); + return FindComponent(testRenderer.Batches.Single()); + } + + private static TComponent FindComponent(CapturedBatch batch) + => batch.ReferenceFrames + .Where(f => f.FrameType == RenderTreeFrameType.Component) + .Select(f => f.Component) + .OfType() + .Single(); + } +} diff --git a/src/Components/Web/test/Forms/InputSelectTest.cs b/src/Components/Web/test/Forms/InputSelectTest.cs index 65c3351e5e..8945867fd0 100644 --- a/src/Components/Web/test/Forms/InputSelectTest.cs +++ b/src/Components/Web/test/Forms/InputSelectTest.cs @@ -2,12 +2,8 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Linq; -using System.Linq.Expressions; +using System.Collections.Generic; using System.Threading.Tasks; -using Microsoft.AspNetCore.Components.Rendering; -using Microsoft.AspNetCore.Components.RenderTree; -using Microsoft.AspNetCore.Components.Test.Helpers; using Xunit; namespace Microsoft.AspNetCore.Components.Forms @@ -19,12 +15,12 @@ namespace Microsoft.AspNetCore.Components.Forms { // Arrange var model = new TestModel(); - var rootComponent = new TestInputSelectHostComponent + var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model), ValueExpression = () => model.NotNullableEnum }; - var inputSelectComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var inputSelectComponent = await InputRenderer.RenderAndGetComponent(rootComponent); // Act inputSelectComponent.CurrentValueAsString = "Two"; @@ -38,12 +34,12 @@ namespace Microsoft.AspNetCore.Components.Forms { // Arrange var model = new TestModel(); - var rootComponent = new TestInputSelectHostComponent + var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model), ValueExpression = () => model.NotNullableEnum }; - var inputSelectComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var inputSelectComponent = await InputRenderer.RenderAndGetComponent(rootComponent); // Act inputSelectComponent.CurrentValueAsString = ""; @@ -57,12 +53,12 @@ namespace Microsoft.AspNetCore.Components.Forms { // Arrange var model = new TestModel(); - var rootComponent = new TestInputSelectHostComponent + var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model), ValueExpression = () => model.NullableEnum }; - var inputSelectComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var inputSelectComponent = await InputRenderer.RenderAndGetComponent(rootComponent); // Act inputSelectComponent.CurrentValueAsString = "Two"; @@ -76,12 +72,12 @@ namespace Microsoft.AspNetCore.Components.Forms { // Arrange var model = new TestModel(); - var rootComponent = new TestInputSelectHostComponent + var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model), ValueExpression = () => model.NullableEnum }; - var inputSelectComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var inputSelectComponent = await InputRenderer.RenderAndGetComponent(rootComponent); // Act inputSelectComponent.CurrentValueAsString = ""; @@ -96,12 +92,12 @@ namespace Microsoft.AspNetCore.Components.Forms { // Arrange var model = new TestModel(); - var rootComponent = new TestInputSelectHostComponent + var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model), ValueExpression = () => model.NotNullableGuid }; - var inputSelectComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var inputSelectComponent = await InputRenderer.RenderAndGetComponent(rootComponent); // Act var guid = Guid.NewGuid(); @@ -117,12 +113,12 @@ namespace Microsoft.AspNetCore.Components.Forms { // Arrange var model = new TestModel(); - var rootComponent = new TestInputSelectHostComponent + var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model), ValueExpression = () => model.NullableGuid }; - var inputSelectComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var inputSelectComponent = await InputRenderer.RenderAndGetComponent(rootComponent); // Act var guid = Guid.NewGuid(); @@ -138,12 +134,12 @@ namespace Microsoft.AspNetCore.Components.Forms { // Arrange var model = new TestModel(); - var rootComponent = new TestInputSelectHostComponent + var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model), ValueExpression = () => model.NotNullableInt }; - var inputSelectComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var inputSelectComponent = await InputRenderer.RenderAndGetComponent(rootComponent); // Act inputSelectComponent.CurrentValueAsString = "42"; @@ -158,12 +154,12 @@ namespace Microsoft.AspNetCore.Components.Forms { // Arrange var model = new TestModel(); - var rootComponent = new TestInputSelectHostComponent + var rootComponent = new TestInputHostComponent> { EditContext = new EditContext(model), ValueExpression = () => model.NullableInt }; - var inputSelectComponent = await RenderAndGetTestInputComponentAsync(rootComponent); + var inputSelectComponent = await InputRenderer.RenderAndGetComponent(rootComponent); // Act inputSelectComponent.CurrentValueAsString = "42"; @@ -172,19 +168,30 @@ namespace Microsoft.AspNetCore.Components.Forms Assert.Equal(42, inputSelectComponent.CurrentValue); } - private static TestInputSelect FindInputSelectComponent(CapturedBatch batch) - => batch.ReferenceFrames - .Where(f => f.FrameType == RenderTreeFrameType.Component) - .Select(f => f.Component) - .OfType>() - .Single(); - - private static async Task> RenderAndGetTestInputComponentAsync(TestInputSelectHostComponent hostComponent) + [Fact] + public async Task ValidationErrorUsesDisplayAttributeName() { - var testRenderer = new TestRenderer(); - var componentId = testRenderer.AssignRootComponentId(hostComponent); - await testRenderer.RenderRootComponentAsync(componentId); - return FindInputSelectComponent(testRenderer.Batches.Single()); + // Arrange + var model = new TestModel(); + var rootComponent = new TestInputHostComponent> + { + EditContext = new EditContext(model), + ValueExpression = () => model.NotNullableInt, + AdditionalAttributes = new Dictionary + { + { "DisplayName", "Some number" } + } + }; + var fieldIdentifier = FieldIdentifier.Create(() => model.NotNullableInt); + var inputSelectComponent = await InputRenderer.RenderAndGetComponent(rootComponent); + + // Act + await inputSelectComponent.SetCurrentValueAsStringAsync("invalidNumber"); + + // Assert + var validationMessages = rootComponent.EditContext.GetValidationMessages(fieldIdentifier); + Assert.NotEmpty(validationMessages); + Assert.Contains("The Some number field is not valid.", validationMessages); } enum TestEnum @@ -218,25 +225,13 @@ namespace Microsoft.AspNetCore.Components.Forms get => base.CurrentValueAsString; set => base.CurrentValueAsString = value; } - } - - class TestInputSelectHostComponent : AutoRenderComponent - { - public EditContext EditContext { get; set; } - - public Expression> ValueExpression { get; set; } - - protected override void BuildRenderTree(RenderTreeBuilder builder) + public async Task SetCurrentValueAsStringAsync(string value) { - builder.OpenComponent>(0); - builder.AddAttribute(1, "Value", EditContext); - builder.AddAttribute(2, "ChildContent", new RenderFragment(childBuilder => - { - childBuilder.OpenComponent>(0); - childBuilder.AddAttribute(0, "ValueExpression", ValueExpression); - childBuilder.CloseComponent(); - })); - builder.CloseComponent(); + // This is equivalent to the subclass writing to CurrentValueAsString + // (e.g., from @bind), except to simplify the test code there's an InvokeAsync + // here. In production code it wouldn't normally be required because @bind + // calls run on the sync context anyway. + await InvokeAsync(() => { base.CurrentValueAsString = value; }); } } } diff --git a/src/Components/Web/test/Forms/TestInputHostComponent.cs b/src/Components/Web/test/Forms/TestInputHostComponent.cs new file mode 100644 index 0000000000..4eb194a718 --- /dev/null +++ b/src/Components/Web/test/Forms/TestInputHostComponent.cs @@ -0,0 +1,41 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.Test.Helpers; + +namespace Microsoft.AspNetCore.Components.Forms +{ + internal class TestInputHostComponent : AutoRenderComponent where TComponent : InputBase + { + public Dictionary AdditionalAttributes { get; set; } + + public EditContext EditContext { get; set; } + + public TValue Value { get; set; } + + public Action ValueChanged { get; set; } + + public Expression> ValueExpression { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Value", EditContext); + builder.AddAttribute(2, "ChildContent", new RenderFragment(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.AddAttribute(0, "Value", Value); + childBuilder.AddAttribute(1, "ValueChanged", + EventCallback.Factory.Create(this, ValueChanged)); + childBuilder.AddAttribute(2, "ValueExpression", ValueExpression); + childBuilder.AddMultipleAttributes(3, AdditionalAttributes); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + } + } +}