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:
parent
b7d9e8cfea
commit
a729c4230e
|
|
@ -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() { }
|
||||
|
|
|
|||
|
|
@ -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() { }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue