diff --git a/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs b/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs index 0f31f41fc171..795dbe6ea3ed 100644 --- a/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs +++ b/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs @@ -61,7 +61,7 @@ protected InputBase() { } 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 @@ protected InputBase() { } [Microsoft.AspNetCore.Components.ParameterAttribute] public System.Linq.Expressions.Expression>? 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 @@ public InputDate() { } [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 : Microsoft.AspNetCore.Components.Forms.InputBase @@ -97,9 +97,34 @@ public InputNumber() { } [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 : Microsoft.AspNetCore.Components.Forms.InputBase + { + 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 : Microsoft.AspNetCore.Components.ComponentBase + { + public InputRadio() { } + [Microsoft.AspNetCore.Components.ParameterAttribute(CaptureUnmatchedValues=true)] + public System.Collections.Generic.IReadOnlyDictionary? 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 : Microsoft.AspNetCore.Components.Forms.InputBase { public InputSelect() { } diff --git a/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netstandard2.0.cs b/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netstandard2.0.cs index 7f99fb8ad414..e224abdafcd1 100644 --- a/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netstandard2.0.cs +++ b/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netstandard2.0.cs @@ -59,7 +59,7 @@ protected InputBase() { } 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 @@ protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Renderin 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 : Microsoft.AspNetCore.Components.Forms.InputBase + { + 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 : Microsoft.AspNetCore.Components.ComponentBase + { + public InputRadio() { } + [Microsoft.AspNetCore.Components.ParameterAttribute(CaptureUnmatchedValues=true)] + public System.Collections.Generic.IReadOnlyDictionary? 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 : Microsoft.AspNetCore.Components.Forms.InputBase { public InputSelect() { } diff --git a/src/Components/Web/src/Forms/InputBase.cs b/src/Components/Web/src/Forms/InputBase.cs index b414d3874c03..5a8fceecf822 100644 --- a/src/Components/Web/src/Forms/InputBase.cs +++ b/src/Components/Web/src/Forms/InputBase.cs @@ -58,7 +58,7 @@ public abstract class InputBase : ComponentBase, IDisposable /// /// Gets the for the bound value. /// - protected FieldIdentifier FieldIdentifier { get; set; } + protected internal FieldIdentifier FieldIdentifier { get; set; } /// /// Gets or sets the current value of the input. @@ -142,7 +142,7 @@ protected InputBase() /// /// The value to format. /// A string representation of the value. - protected virtual string? FormatValueAsString(TValue value) + protected virtual string? FormatValueAsString([AllowNull] TValue value) => value?.ToString(); /// diff --git a/src/Components/Web/src/Forms/InputDate.cs b/src/Components/Web/src/Forms/InputDate.cs index 45583a849e72..e7132988b900 100644 --- a/src/Components/Web/src/Forms/InputDate.cs +++ b/src/Components/Web/src/Forms/InputDate.cs @@ -34,7 +34,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) } /// - protected override string FormatValueAsString(TValue value) + protected override string FormatValueAsString([AllowNull] TValue value) { switch (value) { diff --git a/src/Components/Web/src/Forms/InputExtensions.cs b/src/Components/Web/src/Forms/InputExtensions.cs new file mode 100644 index 000000000000..a1ace921410b --- /dev/null +++ b/src/Components/Web/src/Forms/InputExtensions.cs @@ -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(this InputBase input, string? value, [MaybeNull] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage) + { + try + { + if (BindConverter.TryConvertTo(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); + } + } + } +} diff --git a/src/Components/Web/src/Forms/InputNumber.cs b/src/Components/Web/src/Forms/InputNumber.cs index 9f3b75175ed0..515fc5ceac8b 100644 --- a/src/Components/Web/src/Forms/InputNumber.cs +++ b/src/Components/Web/src/Forms/InputNumber.cs @@ -74,7 +74,7 @@ protected override bool TryParseValueFromString(string? value, [MaybeNull] out T /// /// The value to format. /// A string representation of the value. - protected override string? FormatValueAsString(TValue value) + protected override string? FormatValueAsString([AllowNull] TValue value) { // Avoiding a cast to IFormattable to avoid boxing. switch (value) diff --git a/src/Components/Web/src/Forms/InputRadio.cs b/src/Components/Web/src/Forms/InputRadio.cs new file mode 100644 index 000000000000..4a4ad46dc3a5 --- /dev/null +++ b/src/Components/Web/src/Forms/InputRadio.cs @@ -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 +{ + /// + /// An input component used for selecting a value from a group of choices. + /// + public class InputRadio : ComponentBase + { + /// + /// Gets context for this . + /// + internal InputRadioContext? Context { get; private set; } + + /// + /// Gets or sets a collection of additional attributes that will be applied to the input element. + /// + [Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary? AdditionalAttributes { get; set; } + + /// + /// Gets or sets the value of this input. + /// + [AllowNull] + [MaybeNull] + [Parameter] + public TValue Value { get; set; } = default; + + /// + /// Gets or sets the name of the parent input radio group. + /// + [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; + } + + /// + 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)} " + + $"with a matching 'Name' property, if specified."); + } + } + + /// + 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(); + } + } +} diff --git a/src/Components/Web/src/Forms/InputRadioContext.cs b/src/Components/Web/src/Forms/InputRadioContext.cs new file mode 100644 index 000000000000..45f302871bec --- /dev/null +++ b/src/Components/Web/src/Forms/InputRadioContext.cs @@ -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 +{ + /// + /// Describes context for an component. + /// + internal class InputRadioContext + { + private readonly InputRadioContext? _parentContext; + + /// + /// Gets the name of the input radio group. + /// + public string GroupName { get; } + + /// + /// Gets the current selected value in the input radio group. + /// + public object? CurrentValue { get; } + + /// + /// Gets a css class indicating the validation state of input radio elements. + /// + public string FieldClass { get; } + + /// + /// Gets the event callback to be invoked when the selected value is changed. + /// + public EventCallback ChangeEventCallback { get; } + + /// + /// Instantiates a new . + /// + /// The parent . + /// The name of the input radio group. + /// The current selected value in the input radio group. + /// The css class indicating the validation state of input radio elements. + /// The event callback to be invoked when the selected value is changed. + public InputRadioContext( + InputRadioContext? parentContext, + string groupName, + object? currentValue, + string fieldClass, + EventCallback changeEventCallback) + { + _parentContext = parentContext; + + GroupName = groupName; + CurrentValue = currentValue; + FieldClass = fieldClass; + ChangeEventCallback = changeEventCallback; + } + + /// + /// Finds an in the context's ancestors with the matching . + /// + /// The group name of the ancestor . + /// The , or null if none was found. + public InputRadioContext? FindContextInAncestors(string groupName) + => string.Equals(GroupName, groupName) ? this : _parentContext?.FindContextInAncestors(groupName); + } +} diff --git a/src/Components/Web/src/Forms/InputRadioGroup.cs b/src/Components/Web/src/Forms/InputRadioGroup.cs new file mode 100644 index 000000000000..0e30fd483e09 --- /dev/null +++ b/src/Components/Web/src/Forms/InputRadioGroup.cs @@ -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 +{ + /// + /// Groups child components. + /// + public class InputRadioGroup : InputBase + { + private readonly string _defaultGroupName = Guid.NewGuid().ToString("N"); + private InputRadioContext? _context; + + /// + /// Gets or sets the child content to be rendering inside the . + /// + [Parameter] public RenderFragment? ChildContent { get; set; } + + /// + /// Gets or sets the name of the group. + /// + [Parameter] public string? Name { get; set; } + + [CascadingParameter] private InputRadioContext? CascadedContext { get; set; } + + /// + protected override void OnParametersSet() + { + var groupName = !string.IsNullOrEmpty(Name) ? Name : _defaultGroupName; + var fieldClass = EditContext.FieldCssClass(FieldIdentifier); + var changeEventCallback = EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString); + + _context = new InputRadioContext(CascadedContext, groupName, CurrentValue, fieldClass, changeEventCallback); + } + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + Debug.Assert(_context != null); + + builder.OpenComponent>(2); + builder.AddAttribute(3, "IsFixed", true); + builder.AddAttribute(4, "Value", _context); + builder.AddAttribute(5, "ChildContent", ChildContent); + builder.CloseComponent(); + } + + /// + protected override bool TryParseValueFromString(string? value, [MaybeNull] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage) + => this.TryParseSelectableValueFromString(value, out result, out validationErrorMessage); + } +} diff --git a/src/Components/Web/src/Forms/InputSelect.cs b/src/Components/Web/src/Forms/InputSelect.cs index c5e6e54a943c..b7d5dd702552 100644 --- a/src/Components/Web/src/Forms/InputSelect.cs +++ b/src/Components/Web/src/Forms/InputSelect.cs @@ -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 /// public class InputSelect : InputBase { - private static readonly Type? _nullableUnderlyingType = Nullable.GetUnderlyingType(typeof(TValue)); - /// /// Gets or sets the child content to be rendering inside the select element. /// @@ -34,31 +30,6 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) /// 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(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); } } diff --git a/src/Components/Web/src/Properties/AssemblyInfo.cs b/src/Components/Web/src/Properties/AssemblyInfo.cs index 2741560028c3..891ea5326c15 100644 --- a/src/Components/Web/src/Properties/AssemblyInfo.cs +++ b/src/Components/Web/src/Properties/AssemblyInfo.cs @@ -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")] diff --git a/src/Components/Web/test/Forms/InputRadioTest.cs b/src/Components/Web/test/Forms/InputRadioTest.cs new file mode 100644 index 000000000000..1447a16316f3 --- /dev/null +++ b/src/Components/Web/test/Forms/InputRadioTest.cs @@ -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 + { + EditContext = new EditContext(model), + InnerContent = RadioButtonsWithoutGroup(null) + }; + + var ex = await Assert.ThrowsAsync(() => RenderAndGetTestInputComponentAsync(rootComponent)); + Assert.Contains($"must have an ancestor", ex.Message); + } + + [Fact] + public async Task GroupGeneratesNameGuidWhenInvalidNameSupplied() + { + var model = new TestModel(); + var rootComponent = new TestInputRadioHostComponent + { + 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 + { + 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(0); + builder.AddAttribute(1, "Name", name); + builder.AddAttribute(2, "Value", selectedValue); + builder.CloseComponent(); + } + }; + + private static RenderFragment RadioButtonsWithGroup(string name, Expression> valueExpression) => (builder) => + { + builder.OpenComponent>(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(0); + childBuilder.AddAttribute(1, "Value", value); + childBuilder.CloseComponent(); + } + })); + + builder.CloseComponent(); + }; + + private static IEnumerable FindInputRadioComponents(CapturedBatch batch) + => batch.ReferenceFrames + .Where(f => f.FrameType == RenderTreeFrameType.Component) + .Select(f => f.Component) + .OfType(); + + private static async Task> RenderAndGetTestInputComponentAsync(TestInputRadioHostComponent 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 + { + public string GroupName => Context.GroupName; + } + + private class TestInputRadioHostComponent : AutoRenderComponent + { + public EditContext EditContext { get; set; } + + public RenderFragment InnerContent { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Value", EditContext); + builder.AddAttribute(2, "ChildContent", InnerContent); + builder.CloseComponent(); + } + } + } +} diff --git a/src/Components/Web/test/Forms/InputSelectTest.cs b/src/Components/Web/test/Forms/InputSelectTest.cs index 7c49fe03be56..65c3351e5eea 100644 --- a/src/Components/Web/test/Forms/InputSelectTest.cs +++ b/src/Components/Web/test/Forms/InputSelectTest.cs @@ -90,6 +90,88 @@ public async Task ParsesCurrentValueWhenUsingNullableEnumWithEmptyValue() 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 + { + 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 + { + 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 + { + 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 + { + 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 FindInputSelectComponent(CapturedBatch batch) => batch.ReferenceFrames .Where(f => f.FrameType == RenderTreeFrameType.Component) @@ -117,6 +199,14 @@ class TestModel 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 : InputSelect diff --git a/src/Components/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs index 90d298502d78..15495dc7da87 100644 --- a/src/Components/test/E2ETest/Tests/FormsTest.cs +++ b/src/Components/test/E2ETest/Tests/FormsTest.cs @@ -301,6 +301,68 @@ public void InputCheckboxInteractsWithEditContext() 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() { diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor index 69c2f585017c..4cf0a9d80f42 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor @@ -40,6 +40,32 @@ @person.TicketClass

+

+ + Airline: +
+ @foreach (var airline in (Airline[])Enum.GetValues(typeof(Airline))) + { + + @airline.ToString(); +
+ } +
+

+

+ Pick one color and one country: + + + red
+ japan
+ green
+ yemen
+ blue
+ latvia
+ orange
+
+
+

Accepts terms:

@@ -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 submissionLog = new List(); // So we can assert about the callbacks void HandleValidSubmit()