InputRadio component with form support (#23415)

* Started on InputRadio forms component.

* Added E2E test for InputRadio.

* Added docstring for InputRadio.

* Changed value to be serialized using BindConverter.

* Added InputChoice for choice-based inputs.

InputChoice contains checks for valid choice types that used to exist in InputSelect. Both InputSelect and InputRadio now derive from InputChoice and thus also contain those checks.

* Added InputRadioGroup.

* Small fix.

* Removed InputChoice, cleaned up.

* Added internal access modifier to InputExtensions.

* Small improvements.

* Updated an outdated exception message.

* Updated test to reflect updated exception message.

* Improved API to enforce InputRadioGroup.

* Added support for InputSelect int and Guid bindings.

* Changed validation CSS classes to influence InputRadio components.
This commit is contained in:
Mackinnon Buck 2020-07-02 11:48:34 -07:00 committed by GitHub
parent b7d9e8cfea
commit a729c4230e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 629 additions and 39 deletions

View File

@ -61,7 +61,7 @@ namespace Microsoft.AspNetCore.Components.Forms
protected TValue CurrentValue { get { throw null; } set { } }
protected string? CurrentValueAsString { get { throw null; } set { } }
protected Microsoft.AspNetCore.Components.Forms.EditContext EditContext { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
protected Microsoft.AspNetCore.Components.Forms.FieldIdentifier FieldIdentifier { [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]
[System.Diagnostics.CodeAnalysis.MaybeNullAttribute]
[System.Diagnostics.CodeAnalysis.AllowNullAttribute]
@ -71,7 +71,7 @@ namespace Microsoft.AspNetCore.Components.Forms
[Microsoft.AspNetCore.Components.ParameterAttribute]
public System.Linq.Expressions.Expression<System.Func<TValue>>? ValueExpression { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
protected virtual void Dispose(bool disposing) { }
protected virtual string? FormatValueAsString(TValue value) { throw null; }
protected virtual string? FormatValueAsString([System.Diagnostics.CodeAnalysis.AllowNullAttribute] TValue value) { throw null; }
public override System.Threading.Tasks.Task SetParametersAsync(Microsoft.AspNetCore.Components.ParameterView parameters) { throw null; }
void System.IDisposable.Dispose() { }
protected abstract bool TryParseValueFromString(string? value, [System.Diagnostics.CodeAnalysis.MaybeNullAttribute] out TValue result, [System.Diagnostics.CodeAnalysis.NotNullWhenAttribute(false)] out string? validationErrorMessage);
@ -88,7 +88,7 @@ namespace Microsoft.AspNetCore.Components.Forms
[Microsoft.AspNetCore.Components.ParameterAttribute]
public string ParsingErrorMessage { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { }
protected override string FormatValueAsString(TValue value) { throw null; }
protected override string FormatValueAsString([System.Diagnostics.CodeAnalysis.AllowNullAttribute] TValue value) { throw null; }
protected override bool TryParseValueFromString(string? value, [System.Diagnostics.CodeAnalysis.MaybeNullAttribute] out TValue result, [System.Diagnostics.CodeAnalysis.NotNullWhenAttribute(false)] out string? validationErrorMessage) { throw null; }
}
public partial class InputNumber<TValue> : Microsoft.AspNetCore.Components.Forms.InputBase<TValue>
@ -97,9 +97,34 @@ namespace Microsoft.AspNetCore.Components.Forms
[Microsoft.AspNetCore.Components.ParameterAttribute]
public string ParsingErrorMessage { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { }
protected override string? FormatValueAsString(TValue value) { throw null; }
protected override string? FormatValueAsString([System.Diagnostics.CodeAnalysis.AllowNullAttribute] TValue value) { throw null; }
protected override bool TryParseValueFromString(string? value, [System.Diagnostics.CodeAnalysis.MaybeNullAttribute] out TValue result, [System.Diagnostics.CodeAnalysis.NotNullWhenAttribute(false)] out string? validationErrorMessage) { throw null; }
}
public partial class InputRadioGroup<TValue> : Microsoft.AspNetCore.Components.Forms.InputBase<TValue>
{
public InputRadioGroup() { }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public Microsoft.AspNetCore.Components.RenderFragment? ChildContent { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public string? Name { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { }
protected override void OnParametersSet() { }
protected override bool TryParseValueFromString(string? value, [System.Diagnostics.CodeAnalysis.MaybeNullAttribute] out TValue result, [System.Diagnostics.CodeAnalysis.NotNullWhenAttribute(false)] out string? validationErrorMessage) { throw null; }
}
public partial class InputRadio<TValue> : Microsoft.AspNetCore.Components.ComponentBase
{
public InputRadio() { }
[Microsoft.AspNetCore.Components.ParameterAttribute(CaptureUnmatchedValues=true)]
public System.Collections.Generic.IReadOnlyDictionary<string, object>? AdditionalAttributes { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public string? Name { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
[Microsoft.AspNetCore.Components.ParameterAttribute]
[System.Diagnostics.CodeAnalysis.MaybeNullAttribute]
[System.Diagnostics.CodeAnalysis.AllowNullAttribute]
public TValue Value { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { }
protected override void OnParametersSet() { }
}
public partial class InputSelect<TValue> : Microsoft.AspNetCore.Components.Forms.InputBase<TValue>
{
public InputSelect() { }

View File

@ -59,7 +59,7 @@ namespace Microsoft.AspNetCore.Components.Forms
protected TValue CurrentValue { get { throw null; } set { } }
protected string? CurrentValueAsString { get { throw null; } set { } }
protected Microsoft.AspNetCore.Components.Forms.EditContext EditContext { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
protected Microsoft.AspNetCore.Components.Forms.FieldIdentifier FieldIdentifier { [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]
public TValue Value { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
[Microsoft.AspNetCore.Components.ParameterAttribute]
@ -96,6 +96,29 @@ namespace Microsoft.AspNetCore.Components.Forms
protected override string? FormatValueAsString(TValue value) { throw null; }
protected override bool TryParseValueFromString(string? value, out TValue result, out string? validationErrorMessage) { throw null; }
}
public partial class InputRadioGroup<TValue> : Microsoft.AspNetCore.Components.Forms.InputBase<TValue>
{
public InputRadioGroup() { }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public Microsoft.AspNetCore.Components.RenderFragment? ChildContent { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public string? Name { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { }
protected override void OnParametersSet() { }
protected override bool TryParseValueFromString(string? value, out TValue result, out string? validationErrorMessage) { throw null; }
}
public partial class InputRadio<TValue> : Microsoft.AspNetCore.Components.ComponentBase
{
public InputRadio() { }
[Microsoft.AspNetCore.Components.ParameterAttribute(CaptureUnmatchedValues=true)]
public System.Collections.Generic.IReadOnlyDictionary<string, object>? AdditionalAttributes { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public string? Name { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public TValue Value { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { }
protected override void OnParametersSet() { }
}
public partial class InputSelect<TValue> : Microsoft.AspNetCore.Components.Forms.InputBase<TValue>
{
public InputSelect() { }

View File

@ -58,7 +58,7 @@ namespace Microsoft.AspNetCore.Components.Forms
/// <summary>
/// Gets the <see cref="FieldIdentifier"/> for the bound value.
/// </summary>
protected FieldIdentifier FieldIdentifier { get; set; }
protected internal FieldIdentifier FieldIdentifier { get; set; }
/// <summary>
/// Gets or sets the current value of the input.
@ -142,7 +142,7 @@ namespace Microsoft.AspNetCore.Components.Forms
/// </summary>
/// <param name="value">The value to format.</param>
/// <returns>A string representation of the value.</returns>
protected virtual string? FormatValueAsString(TValue value)
protected virtual string? FormatValueAsString([AllowNull] TValue value)
=> value?.ToString();
/// <summary>

View File

@ -34,7 +34,7 @@ namespace Microsoft.AspNetCore.Components.Forms
}
/// <inheritdoc />
protected override string FormatValueAsString(TValue value)
protected override string FormatValueAsString([AllowNull] TValue value)
{
switch (value)
{

View File

@ -0,0 +1,35 @@
// 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.Diagnostics.CodeAnalysis;
using System.Globalization;
namespace Microsoft.AspNetCore.Components.Forms
{
internal static class InputExtensions
{
public static bool TryParseSelectableValueFromString<TValue>(this InputBase<TValue> input, string? value, [MaybeNull] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage)
{
try
{
if (BindConverter.TryConvertTo<TValue>(value, CultureInfo.CurrentCulture, out var parsedValue))
{
result = parsedValue;
validationErrorMessage = null;
return true;
}
else
{
result = default;
validationErrorMessage = $"The {input.FieldIdentifier.FieldName} field is not valid.";
return false;
}
}
catch (InvalidOperationException ex)
{
throw new InvalidOperationException($"{input.GetType()} does not support the type '{typeof(TValue)}'.", ex);
}
}
}
}

View File

@ -74,7 +74,7 @@ namespace Microsoft.AspNetCore.Components.Forms
/// </summary>
/// <param name="value">The value to format.</param>
/// <returns>A string representation of the value.</returns>
protected override string? FormatValueAsString(TValue value)
protected override string? FormatValueAsString([AllowNull] TValue value)
{
// Avoiding a cast to IFormattable to avoid boxing.
switch (value)

View File

@ -0,0 +1,82 @@
// 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.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components.Rendering;
namespace Microsoft.AspNetCore.Components.Forms
{
/// <summary>
/// An input component used for selecting a value from a group of choices.
/// </summary>
public class InputRadio<TValue> : ComponentBase
{
/// <summary>
/// Gets context for this <see cref="InputRadio{TValue}"/>.
/// </summary>
internal InputRadioContext? Context { get; private set; }
/// <summary>
/// Gets or sets a collection of additional attributes that will be applied to the input element.
/// </summary>
[Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary<string, object>? AdditionalAttributes { get; set; }
/// <summary>
/// Gets or sets the value of this input.
/// </summary>
[AllowNull]
[MaybeNull]
[Parameter]
public TValue Value { get; set; } = default;
/// <summary>
/// Gets or sets the name of the parent input radio group.
/// </summary>
[Parameter] public string? Name { get; set; }
[CascadingParameter] private InputRadioContext? CascadedContext { get; set; }
private string GetCssClass(string fieldClass)
{
if (AdditionalAttributes != null &&
AdditionalAttributes.TryGetValue("class", out var @class) &&
!string.IsNullOrEmpty(Convert.ToString(@class)))
{
return $"{@class} {fieldClass}";
}
return fieldClass;
}
/// <inheritdoc />
protected override void OnParametersSet()
{
Context = string.IsNullOrEmpty(Name) ? CascadedContext : CascadedContext?.FindContextInAncestors(Name);
if (Context == null)
{
throw new InvalidOperationException($"{GetType()} must have an ancestor {typeof(InputRadioGroup<TValue>)} " +
$"with a matching 'Name' property, if specified.");
}
}
/// <inheritdoc />
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
Debug.Assert(Context != null);
builder.OpenElement(0, "input");
builder.AddMultipleAttributes(1, AdditionalAttributes);
builder.AddAttribute(2, "class", GetCssClass(Context.FieldClass));
builder.AddAttribute(3, "type", "radio");
builder.AddAttribute(4, "name", Context.GroupName);
builder.AddAttribute(5, "value", BindConverter.FormatValue(Value?.ToString()));
builder.AddAttribute(6, "checked", Context.CurrentValue?.Equals(Value));
builder.AddAttribute(7, "onchange", Context.ChangeEventCallback);
builder.CloseElement();
}
}
}

View File

@ -0,0 +1,64 @@
// 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.
namespace Microsoft.AspNetCore.Components.Forms
{
/// <summary>
/// Describes context for an <see cref="InputRadio{TValue}"/> component.
/// </summary>
internal class InputRadioContext
{
private readonly InputRadioContext? _parentContext;
/// <summary>
/// Gets the name of the input radio group.
/// </summary>
public string GroupName { get; }
/// <summary>
/// Gets the current selected value in the input radio group.
/// </summary>
public object? CurrentValue { get; }
/// <summary>
/// Gets a css class indicating the validation state of input radio elements.
/// </summary>
public string FieldClass { get; }
/// <summary>
/// Gets the event callback to be invoked when the selected value is changed.
/// </summary>
public EventCallback<ChangeEventArgs> ChangeEventCallback { get; }
/// <summary>
/// Instantiates a new <see cref="InputRadioContext" />.
/// </summary>
/// <param name="parentContext">The parent <see cref="InputRadioContext" />.</param>
/// <param name="groupName">The name of the input radio group.</param>
/// <param name="currentValue">The current selected value in the input radio group.</param>
/// <param name="fieldClass">The css class indicating the validation state of input radio elements.</param>
/// <param name="changeEventCallback">The event callback to be invoked when the selected value is changed.</param>
public InputRadioContext(
InputRadioContext? parentContext,
string groupName,
object? currentValue,
string fieldClass,
EventCallback<ChangeEventArgs> changeEventCallback)
{
_parentContext = parentContext;
GroupName = groupName;
CurrentValue = currentValue;
FieldClass = fieldClass;
ChangeEventCallback = changeEventCallback;
}
/// <summary>
/// Finds an <see cref="InputRadioContext"/> in the context's ancestors with the matching <paramref name="groupName"/>.
/// </summary>
/// <param name="groupName">The group name of the ancestor <see cref="InputRadioContext"/>.</param>
/// <returns>The <see cref="InputRadioContext"/>, or <c>null</c> if none was found.</returns>
public InputRadioContext? FindContextInAncestors(string groupName)
=> string.Equals(GroupName, groupName) ? this : _parentContext?.FindContextInAncestors(groupName);
}
}

View File

@ -0,0 +1,57 @@
// 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.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components.Rendering;
namespace Microsoft.AspNetCore.Components.Forms
{
/// <summary>
/// Groups child <see cref="InputRadio{TValue}"/> components.
/// </summary>
public class InputRadioGroup<TValue> : InputBase<TValue>
{
private readonly string _defaultGroupName = Guid.NewGuid().ToString("N");
private InputRadioContext? _context;
/// <summary>
/// Gets or sets the child content to be rendering inside the <see cref="InputRadioGroup{TValue}"/>.
/// </summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>
/// Gets or sets the name of the group.
/// </summary>
[Parameter] public string? Name { get; set; }
[CascadingParameter] private InputRadioContext? CascadedContext { get; set; }
/// <inheritdoc />
protected override void OnParametersSet()
{
var groupName = !string.IsNullOrEmpty(Name) ? Name : _defaultGroupName;
var fieldClass = EditContext.FieldCssClass(FieldIdentifier);
var changeEventCallback = EventCallback.Factory.CreateBinder<string?>(this, __value => CurrentValueAsString = __value, CurrentValueAsString);
_context = new InputRadioContext(CascadedContext, groupName, CurrentValue, fieldClass, changeEventCallback);
}
/// <inheritdoc />
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
Debug.Assert(_context != null);
builder.OpenComponent<CascadingValue<InputRadioContext>>(2);
builder.AddAttribute(3, "IsFixed", true);
builder.AddAttribute(4, "Value", _context);
builder.AddAttribute(5, "ChildContent", ChildContent);
builder.CloseComponent();
}
/// <inheritdoc />
protected override bool TryParseValueFromString(string? value, [MaybeNull] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage)
=> this.TryParseSelectableValueFromString(value, out result, out validationErrorMessage);
}
}

View File

@ -1,9 +1,7 @@
// 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.Diagnostics.CodeAnalysis;
using System.Globalization;
using Microsoft.AspNetCore.Components.Rendering;
namespace Microsoft.AspNetCore.Components.Forms
@ -13,8 +11,6 @@ namespace Microsoft.AspNetCore.Components.Forms
/// </summary>
public class InputSelect<TValue> : InputBase<TValue>
{
private static readonly Type? _nullableUnderlyingType = Nullable.GetUnderlyingType(typeof(TValue));
/// <summary>
/// Gets or sets the child content to be rendering inside the select element.
/// </summary>
@ -34,31 +30,6 @@ namespace Microsoft.AspNetCore.Components.Forms
/// <inheritdoc />
protected override bool TryParseValueFromString(string? value, [MaybeNull] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage)
{
if (typeof(TValue) == typeof(string))
{
result = (TValue)(object?)value;
validationErrorMessage = null;
return true;
}
else if (typeof(TValue).IsEnum || (_nullableUnderlyingType != null && _nullableUnderlyingType.IsEnum))
{
var success = BindConverter.TryConvertTo<TValue>(value, CultureInfo.CurrentCulture, out var parsedValue);
if (success)
{
result = parsedValue;
validationErrorMessage = null;
return true;
}
else
{
result = default;
validationErrorMessage = $"The {FieldIdentifier.FieldName} field is not valid.";
return false;
}
}
throw new InvalidOperationException($"{GetType()} does not support the type '{typeof(TValue)}'.");
}
=> this.TryParseSelectableValueFromString(value, out result, out validationErrorMessage);
}
}

View File

@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Components.Server.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]
[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Components.Web.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]

View File

@ -0,0 +1,138 @@
// 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;
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;
namespace Microsoft.AspNetCore.Components.Forms
{
public class InputRadioTest
{
[Fact]
public async Task ThrowsOnFirstRenderIfInputRadioHasNoGroup()
{
var model = new TestModel();
var rootComponent = new TestInputRadioHostComponent<TestEnum>
{
EditContext = new EditContext(model),
InnerContent = RadioButtonsWithoutGroup(null)
};
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => RenderAndGetTestInputComponentAsync(rootComponent));
Assert.Contains($"must have an ancestor", ex.Message);
}
[Fact]
public async Task GroupGeneratesNameGuidWhenInvalidNameSupplied()
{
var model = new TestModel();
var rootComponent = new TestInputRadioHostComponent<TestEnum>
{
EditContext = new EditContext(model),
InnerContent = RadioButtonsWithGroup(null, () => model.TestEnum)
};
var inputRadioComponents = await RenderAndGetTestInputComponentAsync(rootComponent);
Assert.All(inputRadioComponents, inputRadio => Assert.True(Guid.TryParseExact(inputRadio.GroupName, "N", out _)));
}
[Fact]
public async Task RadioInputContextExistsWhenValidNameSupplied()
{
var groupName = "group";
var model = new TestModel();
var rootComponent = new TestInputRadioHostComponent<TestEnum>
{
EditContext = new EditContext(model),
InnerContent = RadioButtonsWithGroup(groupName, () => model.TestEnum)
};
var inputRadioComponents = await RenderAndGetTestInputComponentAsync(rootComponent);
Assert.All(inputRadioComponents, inputRadio => Assert.Equal(groupName, inputRadio.GroupName));
}
private static RenderFragment RadioButtonsWithoutGroup(string name) => (builder) =>
{
foreach (var selectedValue in (TestEnum[])Enum.GetValues(typeof(TestEnum)))
{
builder.OpenComponent<TestInputRadio>(0);
builder.AddAttribute(1, "Name", name);
builder.AddAttribute(2, "Value", selectedValue);
builder.CloseComponent();
}
};
private static RenderFragment RadioButtonsWithGroup(string name, Expression<Func<TestEnum>> valueExpression) => (builder) =>
{
builder.OpenComponent<InputRadioGroup<TestEnum>>(0);
builder.AddAttribute(1, "Name", name);
builder.AddAttribute(2, "ValueExpression", valueExpression);
builder.AddAttribute(2, "ChildContent", new RenderFragment((childBuilder) =>
{
foreach (var value in (TestEnum[])Enum.GetValues(typeof(TestEnum)))
{
childBuilder.OpenComponent<TestInputRadio>(0);
childBuilder.AddAttribute(1, "Value", value);
childBuilder.CloseComponent();
}
}));
builder.CloseComponent();
};
private static IEnumerable<TestInputRadio> FindInputRadioComponents(CapturedBatch batch)
=> batch.ReferenceFrames
.Where(f => f.FrameType == RenderTreeFrameType.Component)
.Select(f => f.Component)
.OfType<TestInputRadio>();
private static async Task<IEnumerable<TestInputRadio>> RenderAndGetTestInputComponentAsync(TestInputRadioHostComponent<TestEnum> rootComponent)
{
var testRenderer = new TestRenderer();
var componentId = testRenderer.AssignRootComponentId(rootComponent);
await testRenderer.RenderRootComponentAsync(componentId);
return FindInputRadioComponents(testRenderer.Batches.Single());
}
private enum TestEnum
{
One,
Two,
Three
}
private class TestModel
{
public TestEnum TestEnum { get; set; }
}
private class TestInputRadio : InputRadio<TestEnum>
{
public string GroupName => Context.GroupName;
}
private class TestInputRadioHostComponent<TValue> : AutoRenderComponent
{
public EditContext EditContext { get; set; }
public RenderFragment InnerContent { get; set; }
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenComponent<CascadingValue<EditContext>>(0);
builder.AddAttribute(1, "Value", EditContext);
builder.AddAttribute(2, "ChildContent", InnerContent);
builder.CloseComponent();
}
}
}
}

View File

@ -90,6 +90,88 @@ namespace Microsoft.AspNetCore.Components.Forms
Assert.Null(inputSelectComponent.CurrentValue);
}
// See: https://github.com/dotnet/aspnetcore/issues/9939
[Fact]
public async Task ParsesCurrentValueWhenUsingNotNullableGuid()
{
// Arrange
var model = new TestModel();
var rootComponent = new TestInputSelectHostComponent<Guid>
{
EditContext = new EditContext(model),
ValueExpression = () => model.NotNullableGuid
};
var inputSelectComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
// Act
var guid = Guid.NewGuid();
inputSelectComponent.CurrentValueAsString = guid.ToString();
// Assert
Assert.Equal(guid, inputSelectComponent.CurrentValue);
}
// See: https://github.com/dotnet/aspnetcore/issues/9939
[Fact]
public async Task ParsesCurrentValueWhenUsingNullableGuid()
{
// Arrange
var model = new TestModel();
var rootComponent = new TestInputSelectHostComponent<Guid?>
{
EditContext = new EditContext(model),
ValueExpression = () => model.NullableGuid
};
var inputSelectComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
// Act
var guid = Guid.NewGuid();
inputSelectComponent.CurrentValueAsString = guid.ToString();
// Assert
Assert.Equal(guid, inputSelectComponent.CurrentValue);
}
// See: https://github.com/dotnet/aspnetcore/pull/19562
[Fact]
public async Task ParsesCurrentValueWhenUsingNotNullableInt()
{
// Arrange
var model = new TestModel();
var rootComponent = new TestInputSelectHostComponent<int>
{
EditContext = new EditContext(model),
ValueExpression = () => model.NotNullableInt
};
var inputSelectComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
// Act
inputSelectComponent.CurrentValueAsString = "42";
// Assert
Assert.Equal(42, inputSelectComponent.CurrentValue);
}
// See: https://github.com/dotnet/aspnetcore/pull/19562
[Fact]
public async Task ParsesCurrentValueWhenUsingNullableInt()
{
// Arrange
var model = new TestModel();
var rootComponent = new TestInputSelectHostComponent<int?>
{
EditContext = new EditContext(model),
ValueExpression = () => model.NullableInt
};
var inputSelectComponent = await RenderAndGetTestInputComponentAsync(rootComponent);
// Act
inputSelectComponent.CurrentValueAsString = "42";
// Assert
Assert.Equal(42, inputSelectComponent.CurrentValue);
}
private static TestInputSelect<TValue> FindInputSelectComponent<TValue>(CapturedBatch batch)
=> batch.ReferenceFrames
.Where(f => f.FrameType == RenderTreeFrameType.Component)
@ -117,6 +199,14 @@ namespace Microsoft.AspNetCore.Components.Forms
public TestEnum NotNullableEnum { get; set; }
public TestEnum? NullableEnum { get; set; }
public Guid NotNullableGuid { get; set; }
public Guid? NullableGuid { get; set; }
public int NotNullableInt { get; set; }
public int? NullableInt { get; set; }
}
class TestInputSelect<TValue> : InputSelect<TValue>

View File

@ -301,6 +301,68 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
Browser.Equal(new[] { "Must accept terms", "Must not be evil" }, messagesAccessor);
}
[Fact]
public void InputRadioGroupWithoutNameInteractsWithEditContext()
{
var appElement = MountTypicalValidationComponent();
var airlineInputs = appElement.FindElement(By.ClassName("airline")).FindElements(By.TagName("input"));
var unknownAirlineInput = airlineInputs.First(i => i.GetAttribute("value").Equals("Unknown"));
var bestAirlineInput = airlineInputs.First(i => i.GetAttribute("value").Equals("BestAirline"));
var messagesAccessor = CreateValidationMessagesAccessor(appElement);
// Validate unselected inputs
Assert.All(airlineInputs.Where(i => i != unknownAirlineInput), i => Browser.False(() => i.Selected));
// Validate selected inputs
Browser.True(() => unknownAirlineInput.Selected);
// InputRadio emits additional attributes
Browser.True(() => unknownAirlineInput.GetAttribute("extra").Equals("additional"));
// Validates on edit
Assert.All(airlineInputs, i => Browser.Equal("valid", () => i.GetAttribute("class")));
bestAirlineInput.Click();
Assert.All(airlineInputs, i => Browser.Equal("modified valid", () => i.GetAttribute("class")));
// Can become invalid
unknownAirlineInput.Click();
Assert.All(airlineInputs, i => Browser.Equal("modified invalid", () => i.GetAttribute("class")));
Browser.Equal(new[] { "Pick a valid airline." }, messagesAccessor);
}
[Fact]
public void InputRadioGroupsWithNamesNestedInteractWithEditContext()
{
var appElement = MountTypicalValidationComponent();
var submitButton = appElement.FindElement(By.CssSelector("button[type=submit]"));
var group = appElement.FindElement(By.ClassName("nested-radio-group"));
var countryInputs = group.FindElements(By.Name("country"));
var colorInputs = group.FindElements(By.Name("color"));
// Validate group counts
Assert.Equal(3, countryInputs.Count);
Assert.Equal(4, colorInputs.Count);
// Validate unselected inputs
Assert.All(countryInputs, i => Browser.False(() => i.Selected));
Assert.All(colorInputs, i => Browser.False(() => i.Selected));
// Invalidates on submit
Assert.All(countryInputs, i => Browser.Equal("valid", () => i.GetAttribute("class")));
Assert.All(colorInputs, i => Browser.Equal("valid", () => i.GetAttribute("class")));
submitButton.Click();
Assert.All(countryInputs, i => Browser.Equal("invalid", () => i.GetAttribute("class")));
Assert.All(colorInputs, i => Browser.Equal("invalid", () => i.GetAttribute("class")));
// Validates on edit
countryInputs.First().Click();
Assert.All(countryInputs, i => Browser.Equal("modified valid", () => i.GetAttribute("class")));
Assert.All(colorInputs, i => Browser.Equal("invalid", () => i.GetAttribute("class")));
colorInputs.First().Click();
Assert.All(colorInputs, i => Browser.Equal("modified valid", () => i.GetAttribute("class")));
}
[Fact]
public void CanWireUpINotifyPropertyChangedToEditContext()
{

View File

@ -40,6 +40,32 @@
</InputSelect>
<span id="selected-ticket-class">@person.TicketClass</span>
</p>
<p class="airline">
<InputRadioGroup @bind-Value="person.Airline">
Airline:
<br>
@foreach (var airline in (Airline[])Enum.GetValues(typeof(Airline)))
{
<InputRadio Value="airline" extra="additional" />
@airline.ToString();
<br>
}
</InputRadioGroup>
</p>
<p class="nested-radio-group">
Pick one color and one country:
<InputRadioGroup Name="country" @bind-Value="person.Country">
<InputRadioGroup Name="color" @bind-Value="person.FavoriteColor">
<InputRadio Name="color" Value="Color.Red" />red<br>
<InputRadio Name="country" Value="Country.Japan" />japan<br>
<InputRadio Name="color" Value="Color.Green" />green<br>
<InputRadio Name="country" Value="Country.Yemen" />yemen<br>
<InputRadio Name="color" Value="Color.Blue" />blue<br>
<InputRadio Name="country" Value="Country.Latvia" />latvia<br>
<InputRadio Name="color" Value="Color.Orange" />orange<br>
</InputRadioGroup>
</InputRadioGroup>
</p>
<p class="accepts-terms">
Accepts terms: <InputCheckbox @bind-Value="person.AcceptsTerms" title="You have to check this" />
</p>
@ -109,11 +135,27 @@
[Required, EnumDataType(typeof(TicketClass))]
public TicketClass TicketClass { get; set; }
[Required]
[Range(typeof(Airline), nameof(Airline.BestAirline), nameof(Airline.NoNameAirline), ErrorMessage = "Pick a valid airline.")]
public Airline Airline { get; set; } = Airline.Unknown;
[Required, EnumDataType(typeof(Color))]
public Color? FavoriteColor { get; set; } = null;
[Required, EnumDataType(typeof(Country))]
public Country? Country { get; set; } = null;
public string Username { get; set; }
}
enum TicketClass { Economy, Premium, First }
enum Airline { BestAirline, CoolAirline, NoNameAirline, Unknown }
enum Color { Red, Green, Blue, Orange }
enum Country { Japan, Yemen, Latvia }
List<string> submissionLog = new List<string>(); // So we can assert about the callbacks
void HandleValidSubmit()