Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ protected InputBase() { }
[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]
Expand Down
6 changes: 6 additions & 0 deletions src/Components/Web/src/Forms/InputBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ public abstract class InputBase<TValue> : ComponentBase, IDisposable
/// </summary>
[Parameter] public Expression<Func<TValue>>? ValueExpression { get; set; }

/// <summary>
/// Gets or sets the display name for this field.
/// <para>This value is used when generating error messages when the input value fails to parse correctly.</para>
/// </summary>
[Parameter] public string? DisplayName { get; set; }

/// <summary>
/// Gets the associated <see cref="Forms.EditContext"/>.
/// </summary>
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web/src/Forms/InputDate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ protected override bool TryParseValueFromString(string? value, [MaybeNull] out T
}
else
{
validationErrorMessage = string.Format(ParsingErrorMessage, FieldIdentifier.FieldName);
validationErrorMessage = string.Format(ParsingErrorMessage, DisplayName ?? FieldIdentifier.FieldName);
return false;
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web/src/Forms/InputExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public static bool TryParseSelectableValueFromString<TValue>(this InputBase<TVal
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;
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web/src/Forms/InputNumber.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ protected override bool TryParseValueFromString(string? value, [MaybeNull] out T
}
else
{
validationErrorMessage = string.Format(ParsingErrorMessage, FieldIdentifier.FieldName);
validationErrorMessage = string.Format(ParsingErrorMessage, DisplayName ?? FieldIdentifier.FieldName);
return false;
}
}
Expand Down
78 changes: 15 additions & 63 deletions src/Components/Web/test/Forms/InputBaseTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

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

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

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

Expand All @@ -68,7 +65,7 @@ public async Task GetsCurrentValueFromValueParameter()
};

// Act
var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);

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

// Act
var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);

// Assert
Assert.Same(rootComponent.EditContext, inputComponent.EditContext);
Expand All @@ -106,7 +103,7 @@ public async Task ExposesFieldIdentifierToSubclass()
};

// Act
var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);

// Assert
Assert.Equal(FieldIdentifier.Create(() => model.StringProperty), inputComponent.FieldIdentifier);
Expand All @@ -123,7 +120,7 @@ public async Task CanReadBackChangesToCurrentValue()
Value = "initial value",
ValueExpression = () => model.StringProperty
};
var inputComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
Assert.Equal("initial value", inputComponent.CurrentValue);

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

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

// Act
Expand All @@ -190,7 +187,7 @@ public async Task WritingToCurrentValueNotifiesEditContext()
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
Expand All @@ -213,7 +210,7 @@ public async Task SuppliesFieldClassCorrespondingToFieldState()
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
Expand Down Expand Up @@ -251,7 +248,7 @@ public async Task CssClassCombinesClassWithFieldClass()
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
Expand All @@ -270,7 +267,7 @@ public async Task SuppliesCurrentValueAsStringWithFormatting()
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);
Expand All @@ -289,7 +286,7 @@ public async Task ParsesCurrentValueAsStringWhenChanged_Valid()
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++; };

Expand Down Expand Up @@ -319,7 +316,7 @@ public async Task ParsesCurrentValueAsStringWhenChanged_Invalid()
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++; };

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

private static TComponent FindComponent<TComponent>(CapturedBatch batch)
=> batch.ReferenceFrames
.Where(f => f.FrameType == RenderTreeFrameType.Component)
.Select(f => f.Component)
.OfType<TComponent>()
.Single();

private static async Task<TComponent> RenderAndGetTestInputComponentAsync<TValue, TComponent>(TestInputHostComponent<TValue, TComponent> hostComponent) where TComponent : TestInputComponent<TValue>
{
var testRenderer = new TestRenderer();
var componentId = testRenderer.AssignRootComponentId(hostComponent);
await testRenderer.RenderRootComponentAsync(componentId);
return FindComponent<TComponent>(testRenderer.Batches.Single());
}

class TestModel
{
public string StringProperty { get; set; }
Expand Down Expand Up @@ -530,7 +512,7 @@ public async Task SetCurrentValueAsStringAsync(string value)
}
}

class TestDateInputComponent : TestInputComponent<DateTime>
private class TestDateInputComponent : TestInputComponent<DateTime>
{
protected override string FormatValueAsString(DateTime value)
=> value.ToString("yyyy/MM/dd");
Expand All @@ -549,35 +531,5 @@ protected override bool TryParseValueFromString(string value, out DateTime resul
}
}
}

class TestInputHostComponent<TValue, TComponent> : AutoRenderComponent where TComponent : TestInputComponent<TValue>
{
public Dictionary<string, object> AdditionalAttributes { get; set; }

public EditContext EditContext { get; set; }

public TValue Value { get; set; }

public Action<TValue> ValueChanged { get; set; }

public Expression<Func<TValue>> ValueExpression { get; set; }

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenComponent<CascadingValue<EditContext>>(0);
builder.AddAttribute(1, "Value", EditContext);
builder.AddAttribute(2, "ChildContent", new RenderFragment(childBuilder =>
{
childBuilder.OpenComponent<TComponent>(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();
}
}
}
}
56 changes: 56 additions & 0 deletions src/Components/Web/test/Forms/InputDateTest.cs
Original file line number Diff line number Diff line change
@@ -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<DateTime, TestInputDateComponent>
{
EditContext = new EditContext(model),
ValueExpression = () => model.DateProperty,
AdditionalAttributes = new Dictionary<string, object>
{
{ "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<DateTime>
{
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; });
}
}
}
}
55 changes: 55 additions & 0 deletions src/Components/Web/test/Forms/InputNumberTest.cs
Original file line number Diff line number Diff line change
@@ -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<int, TestInputNumberComponent>
{
EditContext = new EditContext(model),
ValueExpression = () => model.SomeNumber,
AdditionalAttributes = new Dictionary<string, object>
{
{ "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<int>
{
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; });
}
}
}
}
29 changes: 29 additions & 0 deletions src/Components/Web/test/Forms/InputRenderer.cs
Original file line number Diff line number Diff line change
@@ -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<TComponent> RenderAndGetComponent<TValue, TComponent>(TestInputHostComponent<TValue, TComponent> hostComponent)
where TComponent : InputBase<TValue>
{
var testRenderer = new TestRenderer();
var componentId = testRenderer.AssignRootComponentId(hostComponent);
await testRenderer.RenderRootComponentAsync(componentId);
return FindComponent<TComponent>(testRenderer.Batches.Single());
}

private static TComponent FindComponent<TComponent>(CapturedBatch batch)
=> batch.ReferenceFrames
.Where(f => f.FrameType == RenderTreeFrameType.Component)
.Select(f => f.Component)
.OfType<TComponent>()
.Single();
}
}
Loading