From d82e70362903a8f9333c2036cb272bca707c73f5 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 26 Jun 2020 13:44:49 -0700 Subject: [PATCH 01/15] Started on InputRadio forms component. --- src/Components/Web/src/Forms/InputRadio.cs | 48 +++++++++++++++++++ .../TypicalValidationComponent.razor | 14 ++++++ 2 files changed, 62 insertions(+) create mode 100644 src/Components/Web/src/Forms/InputRadio.cs diff --git a/src/Components/Web/src/Forms/InputRadio.cs b/src/Components/Web/src/Forms/InputRadio.cs new file mode 100644 index 000000000000..08bd800c2c0f --- /dev/null +++ b/src/Components/Web/src/Forms/InputRadio.cs @@ -0,0 +1,48 @@ +// 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.Diagnostics.CodeAnalysis; +using System.Globalization; +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components.Forms +{ + public class InputRadio : InputBase + { + /// + /// Gets or sets the value that will be bound when this radio input is selected. + /// + [AllowNull] + [MaybeNull] + [Parameter] + public TValue SelectedValue { get; set; } = default; + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "input"); + builder.AddMultipleAttributes(1, AdditionalAttributes); + builder.AddAttribute(2, "type", "radio"); + builder.AddAttribute(3, "class", CssClass); + builder.AddAttribute(4, "value", SelectedValue != null ? FormatValueAsString(SelectedValue) : string.Empty); + builder.AddAttribute(5, "checked", SelectedValue?.Equals(CurrentValue)); + builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.CloseElement(); + } + + /// + protected override bool TryParseValueFromString(string? value, [MaybeNull] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage) + { + if (BindConverter.TryConvertTo(value, CultureInfo.CurrentCulture, out result)) + { + validationErrorMessage = null; + return true; + } + else + { + validationErrorMessage = $"The {FieldIdentifier.FieldName} field isn't valid."; + return false; + } + } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor index 69c2f585017c..f7132da0dd80 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor @@ -40,6 +40,14 @@ @person.TicketClass

+

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

Accepts terms:

@@ -109,11 +117,17 @@ [Required, EnumDataType(typeof(TicketClass))] public TicketClass TicketClass { get; set; } + [Required] + [Range(typeof(Airline), nameof(Airline.Alaska), nameof(Airline.Southwest), ErrorMessage = "Don't fly Spirit")] + public Airline Airline { get; set; } = Airline.Spirit; + public string Username { get; set; } } enum TicketClass { Economy, Premium, First } + enum Airline { Alaska, United, Southwest, Spirit } + List submissionLog = new List(); // So we can assert about the callbacks void HandleValidSubmit() From f9058cf0e4dfd5eac621e4d9f062effa78b07a42 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 26 Jun 2020 15:14:36 -0700 Subject: [PATCH 02/15] Added E2E test for InputRadio. --- ...ft.AspNetCore.Components.Web.netcoreapp.cs | 10 +++++++ ...spNetCore.Components.Web.netstandard2.0.cs | 8 +++++ .../test/E2ETest/Tests/FormsTest.cs | 29 +++++++++++++++++++ .../TypicalValidationComponent.razor | 10 ++++--- 4 files changed, 53 insertions(+), 4 deletions(-) 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..78e760b6dd65 100644 --- a/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs +++ b/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs @@ -100,6 +100,16 @@ protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Renderin protected override string? FormatValueAsString(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 InputRadio : Microsoft.AspNetCore.Components.Forms.InputBase + { + public InputRadio() { } + [Microsoft.AspNetCore.Components.ParameterAttribute] + [System.Diagnostics.CodeAnalysis.MaybeNullAttribute] + [System.Diagnostics.CodeAnalysis.AllowNullAttribute] + public TValue SelectedValue { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { } + 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 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..18ce391c7de5 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 @@ -96,6 +96,14 @@ 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 InputRadio : Microsoft.AspNetCore.Components.Forms.InputBase + { + public InputRadio() { } + [Microsoft.AspNetCore.Components.ParameterAttribute] + public TValue SelectedValue { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { } + protected override bool TryParseValueFromString(string? value, out TValue result, out string? validationErrorMessage) { throw null; } + } public partial class InputSelect : Microsoft.AspNetCore.Components.Forms.InputBase { public InputSelect() { } diff --git a/src/Components/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs index 90d298502d78..527c434acadc 100644 --- a/src/Components/test/E2ETest/Tests/FormsTest.cs +++ b/src/Components/test/E2ETest/Tests/FormsTest.cs @@ -301,6 +301,35 @@ public void InputCheckboxInteractsWithEditContext() Browser.Equal(new[] { "Must accept terms", "Must not be evil" }, messagesAccessor); } + [Fact] + public void InputRadioInteractsWithEditContext() + { + 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 CanWireUpINotifyPropertyChangedToEditContext() { diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor index f7132da0dd80..167d0c0fc902 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor @@ -41,9 +41,11 @@ @person.TicketClass

+ Airline: +
@foreach (var airline in (Airline[])Enum.GetValues(typeof(Airline))) { - + @airline.ToString();
} @@ -118,15 +120,15 @@ public TicketClass TicketClass { get; set; } [Required] - [Range(typeof(Airline), nameof(Airline.Alaska), nameof(Airline.Southwest), ErrorMessage = "Don't fly Spirit")] - public Airline Airline { get; set; } = Airline.Spirit; + [Range(typeof(Airline), nameof(Airline.BestAirline), nameof(Airline.NoNameAirline), ErrorMessage = "Pick a valid airline.")] + public Airline Airline { get; set; } = Airline.Unknown; public string Username { get; set; } } enum TicketClass { Economy, Premium, First } - enum Airline { Alaska, United, Southwest, Spirit } + enum Airline { BestAirline, CoolAirline, NoNameAirline, Unknown } List submissionLog = new List(); // So we can assert about the callbacks From 20adb6a7ea9a0f4480346d68e72ca26de758b62b Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 26 Jun 2020 15:37:33 -0700 Subject: [PATCH 03/15] Added docstring for InputRadio. --- src/Components/Web/src/Forms/InputRadio.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Components/Web/src/Forms/InputRadio.cs b/src/Components/Web/src/Forms/InputRadio.cs index 08bd800c2c0f..537e33256cc8 100644 --- a/src/Components/Web/src/Forms/InputRadio.cs +++ b/src/Components/Web/src/Forms/InputRadio.cs @@ -7,6 +7,9 @@ namespace Microsoft.AspNetCore.Components.Forms { + ///

+ /// An input component used for selecting a value from a group of choices. + /// public class InputRadio : InputBase { /// From 0a4672861bb7fa6b90db7a4612f6991181e9af07 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 26 Jun 2020 15:47:28 -0700 Subject: [PATCH 04/15] Changed value to be serialized using BindConverter. --- src/Components/Web/src/Forms/InputRadio.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Components/Web/src/Forms/InputRadio.cs b/src/Components/Web/src/Forms/InputRadio.cs index 537e33256cc8..469f60b0a812 100644 --- a/src/Components/Web/src/Forms/InputRadio.cs +++ b/src/Components/Web/src/Forms/InputRadio.cs @@ -27,7 +27,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddMultipleAttributes(1, AdditionalAttributes); builder.AddAttribute(2, "type", "radio"); builder.AddAttribute(3, "class", CssClass); - builder.AddAttribute(4, "value", SelectedValue != null ? FormatValueAsString(SelectedValue) : string.Empty); + builder.AddAttribute(4, "value", BindConverter.FormatValue(SelectedValue != null ? FormatValueAsString(SelectedValue) : string.Empty)); builder.AddAttribute(5, "checked", SelectedValue?.Equals(CurrentValue)); builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); builder.CloseElement(); From dccec3db6e81a517890c7bf46fcafade39fe6ded Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 26 Jun 2020 16:27:53 -0700 Subject: [PATCH 05/15] 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. --- ...ft.AspNetCore.Components.Web.netcoreapp.cs | 17 ++++--- ...spNetCore.Components.Web.netstandard2.0.cs | 11 +++-- src/Components/Web/src/Forms/InputBase.cs | 2 +- src/Components/Web/src/Forms/InputChoice.cs | 46 +++++++++++++++++++ src/Components/Web/src/Forms/InputDate.cs | 2 +- src/Components/Web/src/Forms/InputNumber.cs | 2 +- src/Components/Web/src/Forms/InputRadio.cs | 20 +------- src/Components/Web/src/Forms/InputSelect.cs | 36 +-------------- 8 files changed, 69 insertions(+), 67 deletions(-) create mode 100644 src/Components/Web/src/Forms/InputChoice.cs diff --git a/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs b/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs index 78e760b6dd65..f37b0c8c4c24 100644 --- a/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs +++ b/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs @@ -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); @@ -82,13 +82,18 @@ public InputCheckbox() { } protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { } protected override bool TryParseValueFromString(string? value, out bool result, [System.Diagnostics.CodeAnalysis.NotNullWhenAttribute(false)] out string? validationErrorMessage) { throw null; } } + public abstract partial class InputChoice : Microsoft.AspNetCore.Components.Forms.InputBase + { + protected InputChoice() { } + 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 InputDate : Microsoft.AspNetCore.Components.Forms.InputBase { 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,10 +102,10 @@ 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 InputRadio : Microsoft.AspNetCore.Components.Forms.InputBase + public partial class InputRadio : Microsoft.AspNetCore.Components.Forms.InputChoice { public InputRadio() { } [Microsoft.AspNetCore.Components.ParameterAttribute] @@ -108,15 +113,13 @@ public InputRadio() { } [System.Diagnostics.CodeAnalysis.AllowNullAttribute] public TValue SelectedValue { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { } - 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 InputSelect : Microsoft.AspNetCore.Components.Forms.InputBase + public partial class InputSelect : Microsoft.AspNetCore.Components.Forms.InputChoice { public InputSelect() { } [Microsoft.AspNetCore.Components.ParameterAttribute] public Microsoft.AspNetCore.Components.RenderFragment? ChildContent { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { } - 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 InputText : Microsoft.AspNetCore.Components.Forms.InputBase { 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 18ce391c7de5..a4d7d4fbd984 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 @@ -78,6 +78,11 @@ public InputCheckbox() { } protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { } protected override bool TryParseValueFromString(string? value, out bool result, out string? validationErrorMessage) { throw null; } } + public abstract partial class InputChoice : Microsoft.AspNetCore.Components.Forms.InputBase + { + protected InputChoice() { } + protected override bool TryParseValueFromString(string? value, out TValue result, out string? validationErrorMessage) { throw null; } + } public partial class InputDate : Microsoft.AspNetCore.Components.Forms.InputBase { public InputDate() { } @@ -96,21 +101,19 @@ 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 InputRadio : Microsoft.AspNetCore.Components.Forms.InputBase + public partial class InputRadio : Microsoft.AspNetCore.Components.Forms.InputChoice { public InputRadio() { } [Microsoft.AspNetCore.Components.ParameterAttribute] public TValue SelectedValue { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { } - protected override bool TryParseValueFromString(string? value, out TValue result, out string? validationErrorMessage) { throw null; } } - public partial class InputSelect : Microsoft.AspNetCore.Components.Forms.InputBase + public partial class InputSelect : Microsoft.AspNetCore.Components.Forms.InputChoice { public InputSelect() { } [Microsoft.AspNetCore.Components.ParameterAttribute] public Microsoft.AspNetCore.Components.RenderFragment? ChildContent { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { } - protected override bool TryParseValueFromString(string? value, out TValue result, out string? validationErrorMessage) { throw null; } } public partial class InputText : Microsoft.AspNetCore.Components.Forms.InputBase { diff --git a/src/Components/Web/src/Forms/InputBase.cs b/src/Components/Web/src/Forms/InputBase.cs index b414d3874c03..f949ffa8ea78 100644 --- a/src/Components/Web/src/Forms/InputBase.cs +++ b/src/Components/Web/src/Forms/InputBase.cs @@ -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/InputChoice.cs b/src/Components/Web/src/Forms/InputChoice.cs new file mode 100644 index 000000000000..98befa2dddd0 --- /dev/null +++ b/src/Components/Web/src/Forms/InputChoice.cs @@ -0,0 +1,46 @@ +// 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 +{ + /// + /// Serves as a base for inputs that have a group of selectable choices. + /// + public abstract class InputChoice : InputBase + { + private static readonly Type? _nullableUnderlyingType = Nullable.GetUnderlyingType(typeof(TValue)); + + /// + 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)}'."); + } + } +} 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/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 index 469f60b0a812..6ee67f4fa991 100644 --- a/src/Components/Web/src/Forms/InputRadio.cs +++ b/src/Components/Web/src/Forms/InputRadio.cs @@ -2,7 +2,6 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Diagnostics.CodeAnalysis; -using System.Globalization; using Microsoft.AspNetCore.Components.Rendering; namespace Microsoft.AspNetCore.Components.Forms @@ -10,7 +9,7 @@ namespace Microsoft.AspNetCore.Components.Forms /// /// An input component used for selecting a value from a group of choices. /// - public class InputRadio : InputBase + public class InputRadio : InputChoice { /// /// Gets or sets the value that will be bound when this radio input is selected. @@ -27,25 +26,10 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddMultipleAttributes(1, AdditionalAttributes); builder.AddAttribute(2, "type", "radio"); builder.AddAttribute(3, "class", CssClass); - builder.AddAttribute(4, "value", BindConverter.FormatValue(SelectedValue != null ? FormatValueAsString(SelectedValue) : string.Empty)); + builder.AddAttribute(4, "value", BindConverter.FormatValue(FormatValueAsString(SelectedValue))); builder.AddAttribute(5, "checked", SelectedValue?.Equals(CurrentValue)); builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); builder.CloseElement(); } - - /// - protected override bool TryParseValueFromString(string? value, [MaybeNull] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage) - { - if (BindConverter.TryConvertTo(value, CultureInfo.CurrentCulture, out result)) - { - validationErrorMessage = null; - return true; - } - else - { - validationErrorMessage = $"The {FieldIdentifier.FieldName} field isn't valid."; - return false; - } - } } } diff --git a/src/Components/Web/src/Forms/InputSelect.cs b/src/Components/Web/src/Forms/InputSelect.cs index c5e6e54a943c..48815891fe83 100644 --- a/src/Components/Web/src/Forms/InputSelect.cs +++ b/src/Components/Web/src/Forms/InputSelect.cs @@ -1,9 +1,6 @@ // 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 @@ -11,10 +8,8 @@ namespace Microsoft.AspNetCore.Components.Forms /// /// A dropdown selection component. /// - public class InputSelect : InputBase + public class InputSelect : InputChoice { - private static readonly Type? _nullableUnderlyingType = Nullable.GetUnderlyingType(typeof(TValue)); - /// /// Gets or sets the child content to be rendering inside the select element. /// @@ -31,34 +26,5 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddContent(5, ChildContent); builder.CloseElement(); } - - /// - 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)}'."); - } } } From 05f4547987b4e11ccd25e6e52be872e4a958f7c4 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 29 Jun 2020 12:33:40 -0700 Subject: [PATCH 06/15] Added InputRadioGroup. --- ...ft.AspNetCore.Components.Web.netcoreapp.cs | 14 ++ ...spNetCore.Components.Web.netstandard2.0.cs | 14 ++ src/Components/Web/src/Forms/InputRadio.cs | 36 +++- .../Web/src/Forms/InputRadioGroup.cs | 45 +++++ .../Web/test/Forms/InputRadioTest.cs | 158 ++++++++++++++++++ .../test/E2ETest/Tests/FormsTest.cs | 23 ++- .../TypicalValidationComponent.razor | 17 ++ 7 files changed, 303 insertions(+), 4 deletions(-) create mode 100644 src/Components/Web/src/Forms/InputRadioGroup.cs create mode 100644 src/Components/Web/test/Forms/InputRadioTest.cs diff --git a/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs b/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs index f37b0c8c4c24..cc96d9850a49 100644 --- a/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs +++ b/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs @@ -105,14 +105,28 @@ protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Renderin 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.ComponentBase + { + 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() { } + } public partial class InputRadio : Microsoft.AspNetCore.Components.Forms.InputChoice { public InputRadio() { } + [Microsoft.AspNetCore.Components.CascadingParameterAttribute(Name="Name")] + public string? CascadedName { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + protected string? Name { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } [Microsoft.AspNetCore.Components.ParameterAttribute] [System.Diagnostics.CodeAnalysis.MaybeNullAttribute] [System.Diagnostics.CodeAnalysis.AllowNullAttribute] public TValue SelectedValue { [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.InputChoice { 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 a4d7d4fbd984..19dcdb050895 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 @@ -101,12 +101,26 @@ 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.ComponentBase + { + 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() { } + } public partial class InputRadio : Microsoft.AspNetCore.Components.Forms.InputChoice { public InputRadio() { } + [Microsoft.AspNetCore.Components.CascadingParameterAttribute(Name="Name")] + public string? CascadedName { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + protected string? Name { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } [Microsoft.AspNetCore.Components.ParameterAttribute] public TValue SelectedValue { [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.InputChoice { diff --git a/src/Components/Web/src/Forms/InputRadio.cs b/src/Components/Web/src/Forms/InputRadio.cs index 6ee67f4fa991..cc15723e1726 100644 --- a/src/Components/Web/src/Forms/InputRadio.cs +++ b/src/Components/Web/src/Forms/InputRadio.cs @@ -1,6 +1,8 @@ // 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; @@ -11,6 +13,11 @@ namespace Microsoft.AspNetCore.Components.Forms /// public class InputRadio : InputChoice { + /// + /// Gets the name of this group. + /// + protected string? Name { get; private set; } + /// /// Gets or sets the value that will be bound when this radio input is selected. /// @@ -19,16 +26,39 @@ public class InputRadio : InputChoice [Parameter] public TValue SelectedValue { get; set; } = default; + /// + /// Gets or sets group name inherited from an ancestor . + /// + [CascadingParameter(Name = nameof(InputRadioGroup.Name))] + public string? CascadedName { get; set; } + + /// + protected override void OnParametersSet() + { + Name = AdditionalAttributes != null && AdditionalAttributes.TryGetValue("name", out var nameAttribute) ? + Convert.ToString(nameAttribute) : + CascadedName; + + if (string.IsNullOrWhiteSpace(Name)) + { + throw new InvalidOperationException($"{GetType()} requires either an explicit 'name' attribute or " + + $"a cascading parameter 'Name'. Normally this is provided by an ancestor {nameof(InputRadioGroup)}."); + } + } + /// protected override void BuildRenderTree(RenderTreeBuilder builder) { + Debug.Assert(Name != null); + builder.OpenElement(0, "input"); builder.AddMultipleAttributes(1, AdditionalAttributes); builder.AddAttribute(2, "type", "radio"); builder.AddAttribute(3, "class", CssClass); - builder.AddAttribute(4, "value", BindConverter.FormatValue(FormatValueAsString(SelectedValue))); - builder.AddAttribute(5, "checked", SelectedValue?.Equals(CurrentValue)); - builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.AddAttribute(4, "name", Name); + builder.AddAttribute(5, "value", BindConverter.FormatValue(FormatValueAsString(SelectedValue))); + builder.AddAttribute(6, "checked", SelectedValue?.Equals(CurrentValue)); + builder.AddAttribute(7, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); builder.CloseElement(); } } diff --git a/src/Components/Web/src/Forms/InputRadioGroup.cs b/src/Components/Web/src/Forms/InputRadioGroup.cs new file mode 100644 index 000000000000..0b1101d3647e --- /dev/null +++ b/src/Components/Web/src/Forms/InputRadioGroup.cs @@ -0,0 +1,45 @@ +// 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 Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components.Forms + /// + /// Groups child components. + /// + public class InputRadioGroup : ComponentBase + { + private string? _name; + + /// + /// 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; } + + /// + protected override void OnParametersSet() + { + _name ??= !string.IsNullOrWhiteSpace(Name) ? Name : Guid.NewGuid().ToString("N"); + } + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + Debug.Assert(_name != null); + + builder.OpenComponent>(0); + builder.AddAttribute(1, "IsFixed", true); + builder.AddAttribute(2, "Name", nameof(Name)); + builder.AddAttribute(3, "Value", _name); + builder.AddAttribute(4, "ChildContent", ChildContent); + builder.CloseComponent(); + } + } +} diff --git a/src/Components/Web/test/Forms/InputRadioTest.cs b/src/Components/Web/test/Forms/InputRadioTest.cs new file mode 100644 index 000000000000..82ae97d221e5 --- /dev/null +++ b/src/Components/Web/test/Forms/InputRadioTest.cs @@ -0,0 +1,158 @@ +// 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 + { + [Theory] + [InlineData(" ")] + [InlineData(null)] + public async Task ThrowsOnFirstRenderIfInvalidNameSuppliedWithoutGroup(string name) + { + var model = new TestModel(); + var rootComponent = new TestInputRadioHostComponent + { + EditContext = new EditContext(model), + InnerContent = RadioButtonsWithoutGroup(name, () => model.TestEnum) + }; + + var testRenderer = new TestRenderer(); + var componentId = testRenderer.AssignRootComponentId(rootComponent); + + var ex = await Assert.ThrowsAsync(() => testRenderer.RenderRootComponentAsync(componentId)); + Assert.Contains($"requires either an explicit 'name' attribute", ex.Message); + } + + [Theory] + [InlineData(" ")] + [InlineData(null)] + public async Task GeneratesNameGuidWhenInvalidNameSuppliedWithGroup(string name) + { + var model = new TestModel(); + var rootComponent = new TestInputRadioHostComponent + { + EditContext = new EditContext(model), + InnerContent = RadioButtonsWithGroup(name, () => model.TestEnum) + }; + + var testRenderer = new TestRenderer(); + var componentId = testRenderer.AssignRootComponentId(rootComponent); + await testRenderer.RenderRootComponentAsync(componentId); + var inputRadioComponents = FindInputRadioComponents(testRenderer.Batches.Single()); + + Assert.All(inputRadioComponents, inputRadio => Assert.True(Guid.TryParseExact(inputRadio.GroupName, "N", out _))); + } + + [Theory] + [MemberData(nameof(GetAllInputRadioGenerators))] + public async Task NameAttributeExistsWhenValidNameSupplied(InputRadioGenerator generator) + { + string groupName = "group"; + + var model = new TestModel(); + var rootComponent = new TestInputRadioHostComponent + { + EditContext = new EditContext(model), + InnerContent = generator(groupName, () => model.TestEnum) + }; + + var testRenderer = new TestRenderer(); + var componentId = testRenderer.AssignRootComponentId(rootComponent); + await testRenderer.RenderRootComponentAsync(componentId); + var inputRadioComponents = FindInputRadioComponents(testRenderer.Batches.Single()); + + Assert.All(inputRadioComponents, inputRadio => Assert.Equal(groupName, inputRadio.GroupName)); + } + + public delegate RenderFragment InputRadioGenerator(string name, Expression> valueExpression); + + private static readonly InputRadioGenerator RadioButtonsWithoutGroup = (name, valueExpression) => (builder) => + { + int sequence = 0; + + foreach (var selectedValue in (TestEnum[])Enum.GetValues(typeof(TestEnum))) + { + builder.OpenComponent(sequence++); + builder.AddAttribute(sequence++, "name", name); + builder.AddAttribute(sequence++, "SelectedValue", selectedValue); + builder.AddAttribute(sequence++, "ValueExpression", valueExpression); + builder.CloseComponent(); + } + }; + + private static readonly InputRadioGenerator RadioButtonsWithGroup = (name, valueExpression) => (builder) => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "Name", name); + builder.AddAttribute(2, "ChildContent", new RenderFragment((childBuilder) => + { + int sequence = 0; + + foreach (var selectedValue in (TestEnum[])Enum.GetValues(typeof(TestEnum))) + { + childBuilder.OpenComponent(sequence++); + childBuilder.AddAttribute(sequence++, "SelectedValue", selectedValue); + childBuilder.AddAttribute(sequence++, "ValueExpression", valueExpression); + childBuilder.CloseComponent(); + } + })); + + builder.CloseComponent(); + }; + + public static IEnumerable GetAllInputRadioGenerators() => new[] + { + new[] { RadioButtonsWithoutGroup }, + new[] { RadioButtonsWithGroup } + }; + + private static IEnumerable FindInputRadioComponents(CapturedBatch batch) + => batch.ReferenceFrames + .Where(f => f.FrameType == RenderTreeFrameType.Component) + .Select(f => f.Component) + .OfType(); + + public enum TestEnum + { + One, + Two, + Three + } + + private class TestModel + { + public TestEnum TestEnum { get; set; } + } + + private class TestInputRadio : InputRadio + { + public string GroupName => Name; + } + + 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/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs index 527c434acadc..61d45ab619f7 100644 --- a/src/Components/test/E2ETest/Tests/FormsTest.cs +++ b/src/Components/test/E2ETest/Tests/FormsTest.cs @@ -302,7 +302,7 @@ public void InputCheckboxInteractsWithEditContext() } [Fact] - public void InputRadioInteractsWithEditContext() + public void InputRadioWithoutGroupInteractsWithEditContext() { var appElement = MountTypicalValidationComponent(); var airlineInputs = appElement.FindElement(By.ClassName("airline")).FindElements(By.TagName("input")); @@ -330,6 +330,27 @@ public void InputRadioInteractsWithEditContext() Browser.Equal(new[] { "Pick a valid airline." }, messagesAccessor); } + [Fact] + public void InputRadioWithGroupInteractsWithEditContext() + { + var appElement = MountTypicalValidationComponent(); + var submitButton = appElement.FindElement(By.CssSelector("button[type=submit]")); + var ratingInputs = appElement.FindElement(By.ClassName("star-rating")).FindElements(By.TagName("input")); + var firstRatingInput = ratingInputs.First(); + + // Validate unselected inputs + Assert.All(ratingInputs, i => Browser.False(() => i.Selected)); + + // Invalidates on submit + Assert.All(ratingInputs, i => Browser.Equal("valid", () => i.GetAttribute("class"))); + submitButton.Click(); + Assert.All(ratingInputs, i => Browser.Equal("invalid", () => i.GetAttribute("class"))); + + // Validates on edit + firstRatingInput.Click(); + Assert.All(ratingInputs, 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 167d0c0fc902..0f8acb6adaf0 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor @@ -50,6 +50,18 @@
}

+

+ Star rating: +
+ + @foreach (var starRating in (StarRating[])Enum.GetValues(typeof(StarRating))) + { + + @starRating.ToString() +
+ } +
+

Accepts terms:

@@ -123,6 +135,9 @@ [Range(typeof(Airline), nameof(Airline.BestAirline), nameof(Airline.NoNameAirline), ErrorMessage = "Pick a valid airline.")] public Airline Airline { get; set; } = Airline.Unknown; + [Required, EnumDataType(typeof(StarRating))] + public StarRating? StarRating { get; set; } = null; + public string Username { get; set; } } @@ -130,6 +145,8 @@ enum Airline { BestAirline, CoolAirline, NoNameAirline, Unknown } + enum StarRating { One, Two, Three, Four, Five } + List submissionLog = new List(); // So we can assert about the callbacks void HandleValidSubmit() From 3cbdd1a984ac302f69ba379a70162276da59aa12 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 29 Jun 2020 12:34:37 -0700 Subject: [PATCH 07/15] Small fix. --- src/Components/Web/src/Forms/InputRadioGroup.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Components/Web/src/Forms/InputRadioGroup.cs b/src/Components/Web/src/Forms/InputRadioGroup.cs index 0b1101d3647e..cbf200696663 100644 --- a/src/Components/Web/src/Forms/InputRadioGroup.cs +++ b/src/Components/Web/src/Forms/InputRadioGroup.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Components.Rendering; namespace Microsoft.AspNetCore.Components.Forms +{ /// /// Groups child components. /// From d6e0bc8ab1c7efbb648e02488e0e9f21af9fa261 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 29 Jun 2020 16:00:09 -0700 Subject: [PATCH 08/15] Removed InputChoice, cleaned up. --- ...ft.AspNetCore.Components.Web.netcoreapp.cs | 17 ++--- ...spNetCore.Components.Web.netstandard2.0.cs | 17 ++--- src/Components/Web/src/Forms/InputBase.cs | 2 +- .../{InputChoice.cs => InputExtensions.cs} | 16 ++-- src/Components/Web/src/Forms/InputRadio.cs | 21 +++--- .../Web/src/Forms/InputRadioGroup.cs | 13 ++-- src/Components/Web/src/Forms/InputSelect.cs | 7 +- .../Web/test/Forms/InputRadioTest.cs | 75 ++++++++++--------- 8 files changed, 81 insertions(+), 87 deletions(-) rename src/Components/Web/src/Forms/{InputChoice.cs => InputExtensions.cs} (55%) 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 cc96d9850a49..4ed77aac3713 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] @@ -82,11 +82,6 @@ public InputCheckbox() { } protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { } protected override bool TryParseValueFromString(string? value, out bool result, [System.Diagnostics.CodeAnalysis.NotNullWhenAttribute(false)] out string? validationErrorMessage) { throw null; } } - public abstract partial class InputChoice : Microsoft.AspNetCore.Components.Forms.InputBase - { - protected InputChoice() { } - 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 InputDate : Microsoft.AspNetCore.Components.Forms.InputBase { public InputDate() { } @@ -115,25 +110,25 @@ public InputRadioGroup() { } protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { } protected override void OnParametersSet() { } } - public partial class InputRadio : Microsoft.AspNetCore.Components.Forms.InputChoice + public partial class InputRadio : Microsoft.AspNetCore.Components.Forms.InputBase { public InputRadio() { } - [Microsoft.AspNetCore.Components.CascadingParameterAttribute(Name="Name")] - public string? CascadedName { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } - protected string? Name { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + protected string? GroupName { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } [Microsoft.AspNetCore.Components.ParameterAttribute] [System.Diagnostics.CodeAnalysis.MaybeNullAttribute] [System.Diagnostics.CodeAnalysis.AllowNullAttribute] public TValue SelectedValue { [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 InputSelect : Microsoft.AspNetCore.Components.Forms.InputChoice + public partial class InputSelect : Microsoft.AspNetCore.Components.Forms.InputBase { public InputSelect() { } [Microsoft.AspNetCore.Components.ParameterAttribute] public Microsoft.AspNetCore.Components.RenderFragment? ChildContent { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { } + 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 InputText : Microsoft.AspNetCore.Components.Forms.InputBase { 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 19dcdb050895..3ea920e66245 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] @@ -78,11 +78,6 @@ public InputCheckbox() { } protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { } protected override bool TryParseValueFromString(string? value, out bool result, out string? validationErrorMessage) { throw null; } } - public abstract partial class InputChoice : Microsoft.AspNetCore.Components.Forms.InputBase - { - protected InputChoice() { } - protected override bool TryParseValueFromString(string? value, out TValue result, out string? validationErrorMessage) { throw null; } - } public partial class InputDate : Microsoft.AspNetCore.Components.Forms.InputBase { public InputDate() { } @@ -111,23 +106,23 @@ public InputRadioGroup() { } protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { } protected override void OnParametersSet() { } } - public partial class InputRadio : Microsoft.AspNetCore.Components.Forms.InputChoice + public partial class InputRadio : Microsoft.AspNetCore.Components.Forms.InputBase { public InputRadio() { } - [Microsoft.AspNetCore.Components.CascadingParameterAttribute(Name="Name")] - public string? CascadedName { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } - protected string? Name { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + protected string? GroupName { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } [Microsoft.AspNetCore.Components.ParameterAttribute] public TValue SelectedValue { [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 InputSelect : Microsoft.AspNetCore.Components.Forms.InputChoice + public partial class InputSelect : Microsoft.AspNetCore.Components.Forms.InputBase { public InputSelect() { } [Microsoft.AspNetCore.Components.ParameterAttribute] public Microsoft.AspNetCore.Components.RenderFragment? ChildContent { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { } + protected override bool TryParseValueFromString(string? value, out TValue result, out string? validationErrorMessage) { throw null; } } public partial class InputText : Microsoft.AspNetCore.Components.Forms.InputBase { diff --git a/src/Components/Web/src/Forms/InputBase.cs b/src/Components/Web/src/Forms/InputBase.cs index f949ffa8ea78..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. diff --git a/src/Components/Web/src/Forms/InputChoice.cs b/src/Components/Web/src/Forms/InputExtensions.cs similarity index 55% rename from src/Components/Web/src/Forms/InputChoice.cs rename to src/Components/Web/src/Forms/InputExtensions.cs index 98befa2dddd0..13cdce937976 100644 --- a/src/Components/Web/src/Forms/InputChoice.cs +++ b/src/Components/Web/src/Forms/InputExtensions.cs @@ -7,15 +7,9 @@ namespace Microsoft.AspNetCore.Components.Forms { - /// - /// Serves as a base for inputs that have a group of selectable choices. - /// - public abstract class InputChoice : InputBase + static class InputExtensions { - private static readonly Type? _nullableUnderlyingType = Nullable.GetUnderlyingType(typeof(TValue)); - - /// - protected override bool TryParseValueFromString(string? value, [MaybeNull] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage) + public static bool TryParseSelectableValueFromString(this InputBase input, string? value, [MaybeNull] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage) { if (typeof(TValue) == typeof(string)) { @@ -23,7 +17,7 @@ protected override bool TryParseValueFromString(string? value, [MaybeNull] out T validationErrorMessage = null; return true; } - else if (typeof(TValue).IsEnum || (_nullableUnderlyingType != null && _nullableUnderlyingType.IsEnum)) + else if (typeof(TValue).IsEnum || (Nullable.GetUnderlyingType(typeof(TValue))?.IsEnum ?? false)) { var success = BindConverter.TryConvertTo(value, CultureInfo.CurrentCulture, out var parsedValue); if (success) @@ -35,12 +29,12 @@ protected override bool TryParseValueFromString(string? value, [MaybeNull] out T else { result = default; - validationErrorMessage = $"The {FieldIdentifier.FieldName} field is not valid."; + validationErrorMessage = $"The {input.FieldIdentifier.FieldName} field is not valid."; return false; } } - throw new InvalidOperationException($"{GetType()} does not support the type '{typeof(TValue)}'."); + throw new InvalidOperationException($"{input.GetType()} does not support the type '{typeof(TValue)}'."); } } } diff --git a/src/Components/Web/src/Forms/InputRadio.cs b/src/Components/Web/src/Forms/InputRadio.cs index cc15723e1726..9dff64be4902 100644 --- a/src/Components/Web/src/Forms/InputRadio.cs +++ b/src/Components/Web/src/Forms/InputRadio.cs @@ -11,12 +11,12 @@ namespace Microsoft.AspNetCore.Components.Forms /// /// An input component used for selecting a value from a group of choices. /// - public class InputRadio : InputChoice + public class InputRadio : InputBase { /// /// Gets the name of this group. /// - protected string? Name { get; private set; } + protected string? GroupName { get; private set; } /// /// Gets or sets the value that will be bound when this radio input is selected. @@ -29,17 +29,16 @@ public class InputRadio : InputChoice /// /// Gets or sets group name inherited from an ancestor . /// - [CascadingParameter(Name = nameof(InputRadioGroup.Name))] - public string? CascadedName { get; set; } + [CascadingParameter] InputRadioGroup? CascadedRadioGroup { get; set; } /// protected override void OnParametersSet() { - Name = AdditionalAttributes != null && AdditionalAttributes.TryGetValue("name", out var nameAttribute) ? + GroupName = AdditionalAttributes != null && AdditionalAttributes.TryGetValue("name", out var nameAttribute) ? Convert.ToString(nameAttribute) : - CascadedName; + CascadedRadioGroup?.GroupName; - if (string.IsNullOrWhiteSpace(Name)) + if (string.IsNullOrEmpty(GroupName)) { throw new InvalidOperationException($"{GetType()} requires either an explicit 'name' attribute or " + $"a cascading parameter 'Name'. Normally this is provided by an ancestor {nameof(InputRadioGroup)}."); @@ -49,17 +48,21 @@ protected override void OnParametersSet() /// protected override void BuildRenderTree(RenderTreeBuilder builder) { - Debug.Assert(Name != null); + Debug.Assert(GroupName != null); builder.OpenElement(0, "input"); builder.AddMultipleAttributes(1, AdditionalAttributes); builder.AddAttribute(2, "type", "radio"); builder.AddAttribute(3, "class", CssClass); - builder.AddAttribute(4, "name", Name); + builder.AddAttribute(4, "name", GroupName); builder.AddAttribute(5, "value", BindConverter.FormatValue(FormatValueAsString(SelectedValue))); builder.AddAttribute(6, "checked", SelectedValue?.Equals(CurrentValue)); builder.AddAttribute(7, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); builder.CloseElement(); } + + /// + 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/InputRadioGroup.cs b/src/Components/Web/src/Forms/InputRadioGroup.cs index cbf200696663..5286c0b74f48 100644 --- a/src/Components/Web/src/Forms/InputRadioGroup.cs +++ b/src/Components/Web/src/Forms/InputRadioGroup.cs @@ -12,7 +12,7 @@ namespace Microsoft.AspNetCore.Components.Forms /// public class InputRadioGroup : ComponentBase { - private string? _name; + internal string? GroupName { get; private set; } /// /// Gets or sets the child content to be rendering inside the . @@ -27,19 +27,18 @@ public class InputRadioGroup : ComponentBase /// protected override void OnParametersSet() { - _name ??= !string.IsNullOrWhiteSpace(Name) ? Name : Guid.NewGuid().ToString("N"); + GroupName ??= !string.IsNullOrEmpty(Name) ? Name : Guid.NewGuid().ToString("N"); } /// protected override void BuildRenderTree(RenderTreeBuilder builder) { - Debug.Assert(_name != null); + Debug.Assert(GroupName != null); - builder.OpenComponent>(0); + builder.OpenComponent>(0); builder.AddAttribute(1, "IsFixed", true); - builder.AddAttribute(2, "Name", nameof(Name)); - builder.AddAttribute(3, "Value", _name); - builder.AddAttribute(4, "ChildContent", ChildContent); + builder.AddAttribute(2, "Value", this); + builder.AddAttribute(3, "ChildContent", ChildContent); builder.CloseComponent(); } } diff --git a/src/Components/Web/src/Forms/InputSelect.cs b/src/Components/Web/src/Forms/InputSelect.cs index 48815891fe83..b7d5dd702552 100644 --- a/src/Components/Web/src/Forms/InputSelect.cs +++ b/src/Components/Web/src/Forms/InputSelect.cs @@ -1,6 +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.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components.Rendering; namespace Microsoft.AspNetCore.Components.Forms @@ -8,7 +9,7 @@ namespace Microsoft.AspNetCore.Components.Forms /// /// A dropdown selection component. /// - public class InputSelect : InputChoice + public class InputSelect : InputBase { /// /// Gets or sets the child content to be rendering inside the select element. @@ -26,5 +27,9 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddContent(5, ChildContent); builder.CloseElement(); } + + /// + 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/test/Forms/InputRadioTest.cs b/src/Components/Web/test/Forms/InputRadioTest.cs index 82ae97d221e5..7feba6fbde3b 100644 --- a/src/Components/Web/test/Forms/InputRadioTest.cs +++ b/src/Components/Web/test/Forms/InputRadioTest.cs @@ -15,67 +15,68 @@ namespace Microsoft.AspNetCore.Components.Forms { public class InputRadioTest { - [Theory] - [InlineData(" ")] - [InlineData(null)] - public async Task ThrowsOnFirstRenderIfInvalidNameSuppliedWithoutGroup(string name) + [Fact] + public async Task ThrowsOnFirstRenderIfInvalidNameSuppliedWithoutGroup() { var model = new TestModel(); var rootComponent = new TestInputRadioHostComponent { EditContext = new EditContext(model), - InnerContent = RadioButtonsWithoutGroup(name, () => model.TestEnum) + InnerContent = RadioButtonsWithoutGroup(null, () => model.TestEnum) }; - var testRenderer = new TestRenderer(); - var componentId = testRenderer.AssignRootComponentId(rootComponent); - - var ex = await Assert.ThrowsAsync(() => testRenderer.RenderRootComponentAsync(componentId)); + var ex = await Assert.ThrowsAsync(() => RenderAndGetTestInputComponentAsync(rootComponent)); Assert.Contains($"requires either an explicit 'name' attribute", ex.Message); } - [Theory] - [InlineData(" ")] - [InlineData(null)] - public async Task GeneratesNameGuidWhenInvalidNameSuppliedWithGroup(string name) + [Fact] + public async Task GeneratesNameGuidWhenInvalidNameSuppliedWithGroup() { var model = new TestModel(); var rootComponent = new TestInputRadioHostComponent { EditContext = new EditContext(model), - InnerContent = RadioButtonsWithGroup(name, () => model.TestEnum) + InnerContent = RadioButtonsWithGroup(null, () => model.TestEnum) }; - var testRenderer = new TestRenderer(); - var componentId = testRenderer.AssignRootComponentId(rootComponent); - await testRenderer.RenderRootComponentAsync(componentId); - var inputRadioComponents = FindInputRadioComponents(testRenderer.Batches.Single()); + var inputRadioComponents = await RenderAndGetTestInputComponentAsync(rootComponent); Assert.All(inputRadioComponents, inputRadio => Assert.True(Guid.TryParseExact(inputRadio.GroupName, "N", out _))); } - [Theory] - [MemberData(nameof(GetAllInputRadioGenerators))] - public async Task NameAttributeExistsWhenValidNameSupplied(InputRadioGenerator generator) + [Fact] + public async Task NameAttributeExistsWhenValidNameSupplied_WithoutGroup() { - string groupName = "group"; + var groupName = "group"; + var model = new TestModel(); + var rootComponent = new TestInputRadioHostComponent + { + EditContext = new EditContext(model), + InnerContent = RadioButtonsWithoutGroup(groupName, () => model.TestEnum) + }; + + var inputRadioComponents = await RenderAndGetTestInputComponentAsync(rootComponent); + + Assert.All(inputRadioComponents, inputRadio => Assert.Equal(groupName, inputRadio.GroupName)); + } + [Fact] + public async Task NameAttributeExistsWhenValidNameSupplied_WithGroup() + { + var groupName = "group"; var model = new TestModel(); var rootComponent = new TestInputRadioHostComponent { EditContext = new EditContext(model), - InnerContent = generator(groupName, () => model.TestEnum) + InnerContent = RadioButtonsWithGroup(groupName, () => model.TestEnum) }; - var testRenderer = new TestRenderer(); - var componentId = testRenderer.AssignRootComponentId(rootComponent); - await testRenderer.RenderRootComponentAsync(componentId); - var inputRadioComponents = FindInputRadioComponents(testRenderer.Batches.Single()); + var inputRadioComponents = await RenderAndGetTestInputComponentAsync(rootComponent); Assert.All(inputRadioComponents, inputRadio => Assert.Equal(groupName, inputRadio.GroupName)); } - public delegate RenderFragment InputRadioGenerator(string name, Expression> valueExpression); + private delegate RenderFragment InputRadioGenerator(string name, Expression> valueExpression); private static readonly InputRadioGenerator RadioButtonsWithoutGroup = (name, valueExpression) => (builder) => { @@ -111,19 +112,21 @@ public async Task NameAttributeExistsWhenValidNameSupplied(InputRadioGenerator g builder.CloseComponent(); }; - public static IEnumerable GetAllInputRadioGenerators() => new[] - { - new[] { RadioButtonsWithoutGroup }, - new[] { RadioButtonsWithGroup } - }; - private static IEnumerable FindInputRadioComponents(CapturedBatch batch) => batch.ReferenceFrames .Where(f => f.FrameType == RenderTreeFrameType.Component) .Select(f => f.Component) .OfType(); - public enum TestEnum + 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, @@ -137,7 +140,7 @@ private class TestModel private class TestInputRadio : InputRadio { - public string GroupName => Name; + public new string GroupName => base.GroupName; } private class TestInputRadioHostComponent : AutoRenderComponent From f278c64999daa177bd2f5af7f1afd198e1c7ed27 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 29 Jun 2020 17:48:03 -0700 Subject: [PATCH 09/15] Added internal access modifier to InputExtensions. --- src/Components/Web/src/Forms/InputExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Components/Web/src/Forms/InputExtensions.cs b/src/Components/Web/src/Forms/InputExtensions.cs index 13cdce937976..ede2a8ced8a4 100644 --- a/src/Components/Web/src/Forms/InputExtensions.cs +++ b/src/Components/Web/src/Forms/InputExtensions.cs @@ -7,7 +7,7 @@ namespace Microsoft.AspNetCore.Components.Forms { - static class InputExtensions + internal static class InputExtensions { public static bool TryParseSelectableValueFromString(this InputBase input, string? value, [MaybeNull] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage) { From 32d0b72b94d0a972157eec79fc917ad0074f0828 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 30 Jun 2020 10:13:31 -0700 Subject: [PATCH 10/15] Small improvements. --- src/Components/Web/src/Forms/InputRadio.cs | 2 +- src/Components/Web/src/Forms/InputRadioGroup.cs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Components/Web/src/Forms/InputRadio.cs b/src/Components/Web/src/Forms/InputRadio.cs index 9dff64be4902..7f5a80095ef4 100644 --- a/src/Components/Web/src/Forms/InputRadio.cs +++ b/src/Components/Web/src/Forms/InputRadio.cs @@ -35,7 +35,7 @@ public class InputRadio : InputBase protected override void OnParametersSet() { GroupName = AdditionalAttributes != null && AdditionalAttributes.TryGetValue("name", out var nameAttribute) ? - Convert.ToString(nameAttribute) : + nameAttribute as string : CascadedRadioGroup?.GroupName; if (string.IsNullOrEmpty(GroupName)) diff --git a/src/Components/Web/src/Forms/InputRadioGroup.cs b/src/Components/Web/src/Forms/InputRadioGroup.cs index 5286c0b74f48..432ebdc8d122 100644 --- a/src/Components/Web/src/Forms/InputRadioGroup.cs +++ b/src/Components/Web/src/Forms/InputRadioGroup.cs @@ -12,6 +12,8 @@ namespace Microsoft.AspNetCore.Components.Forms /// public class InputRadioGroup : ComponentBase { + private readonly string _defaultGroupName = Guid.NewGuid().ToString("N"); + internal string? GroupName { get; private set; } /// @@ -27,7 +29,7 @@ public class InputRadioGroup : ComponentBase /// protected override void OnParametersSet() { - GroupName ??= !string.IsNullOrEmpty(Name) ? Name : Guid.NewGuid().ToString("N"); + GroupName = !string.IsNullOrEmpty(Name) ? Name : _defaultGroupName; } /// From 2dc1d3a6c205b8550d5f9c62b6cc5f6b347a36b3 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 30 Jun 2020 10:19:27 -0700 Subject: [PATCH 11/15] Updated an outdated exception message. --- src/Components/Web/src/Forms/InputRadio.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Components/Web/src/Forms/InputRadio.cs b/src/Components/Web/src/Forms/InputRadio.cs index 7f5a80095ef4..a7aeffafbc6f 100644 --- a/src/Components/Web/src/Forms/InputRadio.cs +++ b/src/Components/Web/src/Forms/InputRadio.cs @@ -40,8 +40,8 @@ protected override void OnParametersSet() if (string.IsNullOrEmpty(GroupName)) { - throw new InvalidOperationException($"{GetType()} requires either an explicit 'name' attribute or " + - $"a cascading parameter 'Name'. Normally this is provided by an ancestor {nameof(InputRadioGroup)}."); + throw new InvalidOperationException($"{GetType()} requires either an explicit string attribute 'name' or " + + $"an ancestor {nameof(InputRadioGroup)}."); } } From 4e93647990f0100b17819a48c57a053d30e37117 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 30 Jun 2020 10:21:08 -0700 Subject: [PATCH 12/15] Updated test to reflect updated exception message. --- src/Components/Web/test/Forms/InputRadioTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Components/Web/test/Forms/InputRadioTest.cs b/src/Components/Web/test/Forms/InputRadioTest.cs index 7feba6fbde3b..53b4dac573d7 100644 --- a/src/Components/Web/test/Forms/InputRadioTest.cs +++ b/src/Components/Web/test/Forms/InputRadioTest.cs @@ -26,7 +26,7 @@ public async Task ThrowsOnFirstRenderIfInvalidNameSuppliedWithoutGroup() }; var ex = await Assert.ThrowsAsync(() => RenderAndGetTestInputComponentAsync(rootComponent)); - Assert.Contains($"requires either an explicit 'name' attribute", ex.Message); + Assert.Contains($"requires either an explicit string attribute 'name' or an ancestor", ex.Message); } [Fact] From 8b15f9cbadc77ecc1d81fa4fe6e9eeadecdc2a53 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 1 Jul 2020 16:32:03 -0700 Subject: [PATCH 13/15] Improved API to enforce InputRadioGroup. --- ...ft.AspNetCore.Components.Web.netcoreapp.cs | 22 ++++++-- ...spNetCore.Components.Web.netstandard2.0.cs | 22 ++++++-- src/Components/Web/src/Forms/InputRadio.cs | 47 ++++++++-------- .../Web/src/Forms/InputRadioContext.cs | 56 +++++++++++++++++++ .../Web/src/Forms/InputRadioGroup.cs | 35 ++++++++---- .../Web/test/Forms/InputRadioTest.cs | 47 +++++----------- .../test/E2ETest/Tests/FormsTest.cs | 40 ++++++++----- .../TypicalValidationComponent.razor | 45 +++++++++------ 8 files changed, 205 insertions(+), 109 deletions(-) create mode 100644 src/Components/Web/src/Forms/InputRadioContext.cs diff --git a/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs b/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs index 4ed77aac3713..3f81492cafd2 100644 --- a/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs +++ b/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs @@ -100,7 +100,15 @@ protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Renderin 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.ComponentBase + public partial class InputRadioContext + { + public InputRadioContext(Microsoft.AspNetCore.Components.Forms.InputRadioContext? parentContext, string groupName, object? currentValue, Microsoft.AspNetCore.Components.EventCallback changeEventCallback) { } + public Microsoft.AspNetCore.Components.EventCallback ChangeEventCallback { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public object? CurrentValue { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string GroupName { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public Microsoft.AspNetCore.Components.Forms.InputRadioContext? FindContextInAncestors(string groupName) { throw null; } + } + public partial class InputRadioGroup : Microsoft.AspNetCore.Components.Forms.InputBase { public InputRadioGroup() { } [Microsoft.AspNetCore.Components.ParameterAttribute] @@ -109,18 +117,22 @@ public InputRadioGroup() { } 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.Forms.InputBase + public partial class InputRadio : Microsoft.AspNetCore.Components.ComponentBase { public InputRadio() { } - protected string? GroupName { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + [Microsoft.AspNetCore.Components.ParameterAttribute(CaptureUnmatchedValues=true)] + public System.Collections.Generic.IReadOnlyDictionary? AdditionalAttributes { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + protected Microsoft.AspNetCore.Components.Forms.InputRadioContext? Context { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + [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 SelectedValue { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + 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() { } - 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 InputSelect : Microsoft.AspNetCore.Components.Forms.InputBase { 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 3ea920e66245..a32e20581bbc 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 @@ -96,7 +96,15 @@ 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.ComponentBase + public partial class InputRadioContext + { + public InputRadioContext(Microsoft.AspNetCore.Components.Forms.InputRadioContext? parentContext, string groupName, object? currentValue, Microsoft.AspNetCore.Components.EventCallback changeEventCallback) { } + public Microsoft.AspNetCore.Components.EventCallback ChangeEventCallback { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public object? CurrentValue { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string GroupName { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public Microsoft.AspNetCore.Components.Forms.InputRadioContext? FindContextInAncestors(string groupName) { throw null; } + } + public partial class InputRadioGroup : Microsoft.AspNetCore.Components.Forms.InputBase { public InputRadioGroup() { } [Microsoft.AspNetCore.Components.ParameterAttribute] @@ -105,16 +113,20 @@ public InputRadioGroup() { } 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.Forms.InputBase + public partial class InputRadio : Microsoft.AspNetCore.Components.ComponentBase { public InputRadio() { } - protected string? GroupName { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + [Microsoft.AspNetCore.Components.ParameterAttribute(CaptureUnmatchedValues=true)] + public System.Collections.Generic.IReadOnlyDictionary? AdditionalAttributes { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + protected Microsoft.AspNetCore.Components.Forms.InputRadioContext? Context { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } [Microsoft.AspNetCore.Components.ParameterAttribute] - public TValue SelectedValue { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + 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() { } - protected override bool TryParseValueFromString(string? value, out TValue result, out string? validationErrorMessage) { throw null; } } public partial class InputSelect : Microsoft.AspNetCore.Components.Forms.InputBase { diff --git a/src/Components/Web/src/Forms/InputRadio.cs b/src/Components/Web/src/Forms/InputRadio.cs index a7aeffafbc6f..1ce8f764fa37 100644 --- a/src/Components/Web/src/Forms/InputRadio.cs +++ b/src/Components/Web/src/Forms/InputRadio.cs @@ -2,6 +2,7 @@ // 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; @@ -11,58 +12,58 @@ namespace Microsoft.AspNetCore.Components.Forms /// /// An input component used for selecting a value from a group of choices. /// - public class InputRadio : InputBase + public class InputRadio : ComponentBase { /// - /// Gets the name of this group. + /// Gets context for this . /// - protected string? GroupName { get; private set; } + protected InputRadioContext? Context { get; private set; } /// - /// Gets or sets the value that will be bound when this radio input is selected. + /// 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 SelectedValue { get; set; } = default; + public TValue Value { get; set; } = default; /// - /// Gets or sets group name inherited from an ancestor . + /// Gets or sets the name of the parent input radio group. /// - [CascadingParameter] InputRadioGroup? CascadedRadioGroup { get; set; } + [Parameter] public string? Name { get; set; } + + [CascadingParameter] private InputRadioContext? CascadedContext { get; set; } /// protected override void OnParametersSet() { - GroupName = AdditionalAttributes != null && AdditionalAttributes.TryGetValue("name", out var nameAttribute) ? - nameAttribute as string : - CascadedRadioGroup?.GroupName; + Context = string.IsNullOrEmpty(Name) ? CascadedContext : CascadedContext?.FindContextInAncestors(Name); - if (string.IsNullOrEmpty(GroupName)) + if (Context == null) { - throw new InvalidOperationException($"{GetType()} requires either an explicit string attribute 'name' or " + - $"an ancestor {nameof(InputRadioGroup)}."); + 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(GroupName != null); + Debug.Assert(Context != null); builder.OpenElement(0, "input"); builder.AddMultipleAttributes(1, AdditionalAttributes); builder.AddAttribute(2, "type", "radio"); - builder.AddAttribute(3, "class", CssClass); - builder.AddAttribute(4, "name", GroupName); - builder.AddAttribute(5, "value", BindConverter.FormatValue(FormatValueAsString(SelectedValue))); - builder.AddAttribute(6, "checked", SelectedValue?.Equals(CurrentValue)); - builder.AddAttribute(7, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.AddAttribute(3, "name", Context.GroupName); + builder.AddAttribute(4, "value", BindConverter.FormatValue(Value?.ToString())); + builder.AddAttribute(5, "checked", Context.CurrentValue?.Equals(Value)); + builder.AddAttribute(6, "onchange", Context.ChangeEventCallback!); builder.CloseElement(); } - - /// - 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/InputRadioContext.cs b/src/Components/Web/src/Forms/InputRadioContext.cs new file mode 100644 index 000000000000..ed867af1ab09 --- /dev/null +++ b/src/Components/Web/src/Forms/InputRadioContext.cs @@ -0,0 +1,56 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Components.Forms +{ + /// + /// Describes context for an component. + /// + public 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 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 event callback to be invoked when the selected value is changed. + public InputRadioContext( + InputRadioContext? parentContext, + string groupName, + object? currentValue, + EventCallback changeEventCallback) + { + _parentContext = parentContext; + + GroupName = groupName; + CurrentValue = currentValue; + 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 index 432ebdc8d122..4029abab1b76 100644 --- a/src/Components/Web/src/Forms/InputRadioGroup.cs +++ b/src/Components/Web/src/Forms/InputRadioGroup.cs @@ -3,6 +3,7 @@ using System; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components.Rendering; namespace Microsoft.AspNetCore.Components.Forms @@ -10,14 +11,13 @@ namespace Microsoft.AspNetCore.Components.Forms /// /// Groups child components. /// - public class InputRadioGroup : ComponentBase + public class InputRadioGroup : InputBase { private readonly string _defaultGroupName = Guid.NewGuid().ToString("N"); - - internal string? GroupName { get; private set; } + private InputRadioContext? _context; /// - /// Gets or sets the child content to be rendering inside the . + /// Gets or sets the child content to be rendering inside the . /// [Parameter] public RenderFragment? ChildContent { get; set; } @@ -26,22 +26,35 @@ public class InputRadioGroup : ComponentBase /// [Parameter] public string? Name { get; set; } + [CascadingParameter] private InputRadioContext? CascadedContext { get; set; } + /// protected override void OnParametersSet() { - GroupName = !string.IsNullOrEmpty(Name) ? Name : _defaultGroupName; + var changeEventCallback = EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString); + var groupName = !string.IsNullOrEmpty(Name) ? Name : _defaultGroupName; + + _context = new InputRadioContext(CascadedContext, groupName, CurrentValue, changeEventCallback); } /// protected override void BuildRenderTree(RenderTreeBuilder builder) { - Debug.Assert(GroupName != null); - - builder.OpenComponent>(0); - builder.AddAttribute(1, "IsFixed", true); - builder.AddAttribute(2, "Value", this); - builder.AddAttribute(3, "ChildContent", ChildContent); + Debug.Assert(_context != null); + + builder.OpenElement(0, "div"); + builder.AddMultipleAttributes(1, AdditionalAttributes); + builder.AddAttribute(2, "class", CssClass); + builder.OpenComponent>(3); + builder.AddAttribute(4, "IsFixed", true); + builder.AddAttribute(5, "Value", _context); + builder.AddAttribute(6, "ChildContent", ChildContent); builder.CloseComponent(); + builder.CloseElement(); } + + /// + 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/test/Forms/InputRadioTest.cs b/src/Components/Web/test/Forms/InputRadioTest.cs index 53b4dac573d7..07bfd0af1ad1 100644 --- a/src/Components/Web/test/Forms/InputRadioTest.cs +++ b/src/Components/Web/test/Forms/InputRadioTest.cs @@ -16,7 +16,7 @@ namespace Microsoft.AspNetCore.Components.Forms public class InputRadioTest { [Fact] - public async Task ThrowsOnFirstRenderIfInvalidNameSuppliedWithoutGroup() + public async Task ThrowsOnFirstRenderIfInputRadioHasNoGroup() { var model = new TestModel(); var rootComponent = new TestInputRadioHostComponent @@ -26,11 +26,11 @@ public async Task ThrowsOnFirstRenderIfInvalidNameSuppliedWithoutGroup() }; var ex = await Assert.ThrowsAsync(() => RenderAndGetTestInputComponentAsync(rootComponent)); - Assert.Contains($"requires either an explicit string attribute 'name' or an ancestor", ex.Message); + Assert.Contains($"must have an ancestor", ex.Message); } [Fact] - public async Task GeneratesNameGuidWhenInvalidNameSuppliedWithGroup() + public async Task GroupGeneratesNameGuidWhenInvalidNameSupplied() { var model = new TestModel(); var rootComponent = new TestInputRadioHostComponent @@ -45,23 +45,7 @@ public async Task GeneratesNameGuidWhenInvalidNameSuppliedWithGroup() } [Fact] - public async Task NameAttributeExistsWhenValidNameSupplied_WithoutGroup() - { - var groupName = "group"; - var model = new TestModel(); - var rootComponent = new TestInputRadioHostComponent - { - EditContext = new EditContext(model), - InnerContent = RadioButtonsWithoutGroup(groupName, () => model.TestEnum) - }; - - var inputRadioComponents = await RenderAndGetTestInputComponentAsync(rootComponent); - - Assert.All(inputRadioComponents, inputRadio => Assert.Equal(groupName, inputRadio.GroupName)); - } - - [Fact] - public async Task NameAttributeExistsWhenValidNameSupplied_WithGroup() + public async Task RadioInputContextExistsWhenValidNameSupplied() { var groupName = "group"; var model = new TestModel(); @@ -80,31 +64,26 @@ public async Task NameAttributeExistsWhenValidNameSupplied_WithGroup() private static readonly InputRadioGenerator RadioButtonsWithoutGroup = (name, valueExpression) => (builder) => { - int sequence = 0; - foreach (var selectedValue in (TestEnum[])Enum.GetValues(typeof(TestEnum))) { - builder.OpenComponent(sequence++); - builder.AddAttribute(sequence++, "name", name); - builder.AddAttribute(sequence++, "SelectedValue", selectedValue); - builder.AddAttribute(sequence++, "ValueExpression", valueExpression); + builder.OpenComponent(0); + builder.AddAttribute(1, "Name", name); + builder.AddAttribute(2, "Value", selectedValue); builder.CloseComponent(); } }; private static readonly InputRadioGenerator RadioButtonsWithGroup = (name, valueExpression) => (builder) => { - builder.OpenComponent(0); + builder.OpenComponent>(0); builder.AddAttribute(1, "Name", name); + builder.AddAttribute(2, "ValueExpression", valueExpression); builder.AddAttribute(2, "ChildContent", new RenderFragment((childBuilder) => { - int sequence = 0; - - foreach (var selectedValue in (TestEnum[])Enum.GetValues(typeof(TestEnum))) + foreach (var value in (TestEnum[])Enum.GetValues(typeof(TestEnum))) { - childBuilder.OpenComponent(sequence++); - childBuilder.AddAttribute(sequence++, "SelectedValue", selectedValue); - childBuilder.AddAttribute(sequence++, "ValueExpression", valueExpression); + childBuilder.OpenComponent(0); + childBuilder.AddAttribute(1, "Value", value); childBuilder.CloseComponent(); } })); @@ -140,7 +119,7 @@ private class TestModel private class TestInputRadio : InputRadio { - public new string GroupName => base.GroupName; + public string GroupName => Context.GroupName; } private class TestInputRadioHostComponent : AutoRenderComponent diff --git a/src/Components/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs index 61d45ab619f7..d055758abfe7 100644 --- a/src/Components/test/E2ETest/Tests/FormsTest.cs +++ b/src/Components/test/E2ETest/Tests/FormsTest.cs @@ -302,10 +302,11 @@ public void InputCheckboxInteractsWithEditContext() } [Fact] - public void InputRadioWithoutGroupInteractsWithEditContext() + public void InputRadioGroupWithoutNameInteractsWithEditContext() { var appElement = MountTypicalValidationComponent(); - var airlineInputs = appElement.FindElement(By.ClassName("airline")).FindElements(By.TagName("input")); + var airlineGroup = appElement.FindElement(By.Id("airline-group")); + var airlineInputs = airlineGroup.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); @@ -320,35 +321,48 @@ public void InputRadioWithoutGroupInteractsWithEditContext() Browser.True(() => unknownAirlineInput.GetAttribute("extra").Equals("additional")); // Validates on edit - Assert.All(airlineInputs, i => Browser.Equal("valid", () => i.GetAttribute("class"))); + Browser.Equal("valid", () => airlineGroup.GetAttribute("class")); bestAirlineInput.Click(); - Assert.All(airlineInputs, i => Browser.Equal("modified valid", () => i.GetAttribute("class"))); + Browser.Equal("modified valid", () => airlineGroup.GetAttribute("class")); // Can become invalid unknownAirlineInput.Click(); - Assert.All(airlineInputs, i => Browser.Equal("modified invalid", () => i.GetAttribute("class"))); + Browser.Equal("modified invalid", () => airlineGroup.GetAttribute("class")); Browser.Equal(new[] { "Pick a valid airline." }, messagesAccessor); } [Fact] - public void InputRadioWithGroupInteractsWithEditContext() + public void InputRadioGroupsWithNamesNestedInteractWithEditContext() { var appElement = MountTypicalValidationComponent(); var submitButton = appElement.FindElement(By.CssSelector("button[type=submit]")); - var ratingInputs = appElement.FindElement(By.ClassName("star-rating")).FindElements(By.TagName("input")); - var firstRatingInput = ratingInputs.First(); + var countryGroup = appElement.FindElement(By.Id("country-group")); + var colorGroup = appElement.FindElement(By.Id("color-group")); + var countryInputs = countryGroup.FindElements(By.Name("country")); + var colorInputs = colorGroup.FindElements(By.Name("color")); + + // Validate group counts + Assert.Equal(3, countryInputs.Count); + Assert.Equal(4, colorInputs.Count); // Validate unselected inputs - Assert.All(ratingInputs, i => Browser.False(() => i.Selected)); + Assert.All(countryInputs, i => Browser.False(() => i.Selected)); + Assert.All(colorInputs, i => Browser.False(() => i.Selected)); // Invalidates on submit - Assert.All(ratingInputs, i => Browser.Equal("valid", () => i.GetAttribute("class"))); + Browser.Equal("valid", () => countryGroup.GetAttribute("class")); + Browser.Equal("valid", () => colorGroup.GetAttribute("class")); submitButton.Click(); - Assert.All(ratingInputs, i => Browser.Equal("invalid", () => i.GetAttribute("class"))); + Browser.Equal("invalid", () => countryGroup.GetAttribute("class")); + Browser.Equal("invalid", () => colorGroup.GetAttribute("class")); // Validates on edit - firstRatingInput.Click(); - Assert.All(ratingInputs, i => Browser.Equal("modified valid", () => i.GetAttribute("class"))); + countryInputs.First().Click(); + Browser.Equal("modified valid", () => countryGroup.GetAttribute("class")); + Browser.Equal("invalid", () => colorGroup.GetAttribute("class")); + + colorInputs.First().Click(); + Browser.Equal("modified valid", () => colorGroup.GetAttribute("class")); } [Fact] diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor index 0f8acb6adaf0..25be43570fdc 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor @@ -41,27 +41,31 @@ @person.TicketClass

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

-

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

+

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

Accepts terms:

@@ -135,8 +139,11 @@ [Range(typeof(Airline), nameof(Airline.BestAirline), nameof(Airline.NoNameAirline), ErrorMessage = "Pick a valid airline.")] public Airline Airline { get; set; } = Airline.Unknown; - [Required, EnumDataType(typeof(StarRating))] - public StarRating? StarRating { get; set; } = null; + [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; } } @@ -145,7 +152,9 @@ enum Airline { BestAirline, CoolAirline, NoNameAirline, Unknown } - enum StarRating { One, Two, Three, Four, Five } + enum Color { Red, Green, Blue, Orange } + + enum Country { Japan, Yemen, Latvia } List submissionLog = new List(); // So we can assert about the callbacks From 3a17be61fd942dfea4c6d0991ef623f130508fdb Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 1 Jul 2020 21:09:01 -0700 Subject: [PATCH 14/15] Added support for InputSelect int and Guid bindings. --- .../Web/src/Forms/InputExtensions.cs | 17 ++-- .../Web/test/Forms/InputRadioTest.cs | 8 +- .../Web/test/Forms/InputSelectTest.cs | 90 +++++++++++++++++++ 3 files changed, 99 insertions(+), 16 deletions(-) diff --git a/src/Components/Web/src/Forms/InputExtensions.cs b/src/Components/Web/src/Forms/InputExtensions.cs index ede2a8ced8a4..a1ace921410b 100644 --- a/src/Components/Web/src/Forms/InputExtensions.cs +++ b/src/Components/Web/src/Forms/InputExtensions.cs @@ -11,16 +11,9 @@ internal static class InputExtensions { public static bool TryParseSelectableValueFromString(this InputBase input, string? value, [MaybeNull] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage) { - if (typeof(TValue) == typeof(string)) + try { - result = (TValue)(object?)value; - validationErrorMessage = null; - return true; - } - else if (typeof(TValue).IsEnum || (Nullable.GetUnderlyingType(typeof(TValue))?.IsEnum ?? false)) - { - var success = BindConverter.TryConvertTo(value, CultureInfo.CurrentCulture, out var parsedValue); - if (success) + if (BindConverter.TryConvertTo(value, CultureInfo.CurrentCulture, out var parsedValue)) { result = parsedValue; validationErrorMessage = null; @@ -33,8 +26,10 @@ public static bool TryParseSelectableValueFromString(this InputBase { EditContext = new EditContext(model), - InnerContent = RadioButtonsWithoutGroup(null, () => model.TestEnum) + InnerContent = RadioButtonsWithoutGroup(null) }; var ex = await Assert.ThrowsAsync(() => RenderAndGetTestInputComponentAsync(rootComponent)); @@ -60,9 +60,7 @@ public async Task RadioInputContextExistsWhenValidNameSupplied() Assert.All(inputRadioComponents, inputRadio => Assert.Equal(groupName, inputRadio.GroupName)); } - private delegate RenderFragment InputRadioGenerator(string name, Expression> valueExpression); - - private static readonly InputRadioGenerator RadioButtonsWithoutGroup = (name, valueExpression) => (builder) => + private static RenderFragment RadioButtonsWithoutGroup(string name) => (builder) => { foreach (var selectedValue in (TestEnum[])Enum.GetValues(typeof(TestEnum))) { @@ -73,7 +71,7 @@ public async Task RadioInputContextExistsWhenValidNameSupplied() } }; - private static readonly InputRadioGenerator RadioButtonsWithGroup = (name, valueExpression) => (builder) => + private static RenderFragment RadioButtonsWithGroup(string name, Expression> valueExpression) => (builder) => { builder.OpenComponent>(0); builder.AddAttribute(1, "Name", name); 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 From ab90f69850c6bafcbe83c654d10ad61c1900fe15 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 2 Jul 2020 10:27:55 -0700 Subject: [PATCH 15/15] Changed validation CSS classes to influence InputRadio components. --- ...ft.AspNetCore.Components.Web.netcoreapp.cs | 9 ------ ...spNetCore.Components.Web.netstandard2.0.cs | 9 ------ src/Components/Web/src/Forms/InputRadio.cs | 25 ++++++++++++---- .../Web/src/Forms/InputRadioContext.cs | 10 ++++++- .../Web/src/Forms/InputRadioGroup.cs | 17 +++++------ .../Web/src/Properties/AssemblyInfo.cs | 1 + .../test/E2ETest/Tests/FormsTest.cs | 30 +++++++++---------- .../TypicalValidationComponent.razor | 6 ++-- 8 files changed, 53 insertions(+), 54 deletions(-) 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 3f81492cafd2..795dbe6ea3ed 100644 --- a/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs +++ b/src/Components/Web/ref/Microsoft.AspNetCore.Components.Web.netcoreapp.cs @@ -100,14 +100,6 @@ protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Renderin 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 InputRadioContext - { - public InputRadioContext(Microsoft.AspNetCore.Components.Forms.InputRadioContext? parentContext, string groupName, object? currentValue, Microsoft.AspNetCore.Components.EventCallback changeEventCallback) { } - public Microsoft.AspNetCore.Components.EventCallback ChangeEventCallback { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } - public object? CurrentValue { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } - public string GroupName { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } - public Microsoft.AspNetCore.Components.Forms.InputRadioContext? FindContextInAncestors(string groupName) { throw null; } - } public partial class InputRadioGroup : Microsoft.AspNetCore.Components.Forms.InputBase { public InputRadioGroup() { } @@ -124,7 +116,6 @@ public partial class InputRadio : Microsoft.AspNetCore.Components.Compon 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 { } } - protected Microsoft.AspNetCore.Components.Forms.InputRadioContext? Context { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } [Microsoft.AspNetCore.Components.ParameterAttribute] public string? Name { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } [Microsoft.AspNetCore.Components.ParameterAttribute] 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 a32e20581bbc..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 @@ -96,14 +96,6 @@ 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 InputRadioContext - { - public InputRadioContext(Microsoft.AspNetCore.Components.Forms.InputRadioContext? parentContext, string groupName, object? currentValue, Microsoft.AspNetCore.Components.EventCallback changeEventCallback) { } - public Microsoft.AspNetCore.Components.EventCallback ChangeEventCallback { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } - public object? CurrentValue { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } - public string GroupName { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } - public Microsoft.AspNetCore.Components.Forms.InputRadioContext? FindContextInAncestors(string groupName) { throw null; } - } public partial class InputRadioGroup : Microsoft.AspNetCore.Components.Forms.InputBase { public InputRadioGroup() { } @@ -120,7 +112,6 @@ public partial class InputRadio : Microsoft.AspNetCore.Components.Compon 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 { } } - protected Microsoft.AspNetCore.Components.Forms.InputRadioContext? Context { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } [Microsoft.AspNetCore.Components.ParameterAttribute] public string? Name { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } [Microsoft.AspNetCore.Components.ParameterAttribute] diff --git a/src/Components/Web/src/Forms/InputRadio.cs b/src/Components/Web/src/Forms/InputRadio.cs index 1ce8f764fa37..4a4ad46dc3a5 100644 --- a/src/Components/Web/src/Forms/InputRadio.cs +++ b/src/Components/Web/src/Forms/InputRadio.cs @@ -17,7 +17,7 @@ public class InputRadio : ComponentBase /// /// Gets context for this . /// - protected InputRadioContext? Context { get; private set; } + internal InputRadioContext? Context { get; private set; } /// /// Gets or sets a collection of additional attributes that will be applied to the input element. @@ -39,6 +39,18 @@ public class InputRadio : ComponentBase [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() { @@ -58,11 +70,12 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.OpenElement(0, "input"); builder.AddMultipleAttributes(1, AdditionalAttributes); - builder.AddAttribute(2, "type", "radio"); - builder.AddAttribute(3, "name", Context.GroupName); - builder.AddAttribute(4, "value", BindConverter.FormatValue(Value?.ToString())); - builder.AddAttribute(5, "checked", Context.CurrentValue?.Equals(Value)); - builder.AddAttribute(6, "onchange", Context.ChangeEventCallback!); + 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 index ed867af1ab09..45f302871bec 100644 --- a/src/Components/Web/src/Forms/InputRadioContext.cs +++ b/src/Components/Web/src/Forms/InputRadioContext.cs @@ -6,7 +6,7 @@ namespace Microsoft.AspNetCore.Components.Forms /// /// Describes context for an component. /// - public class InputRadioContext + internal class InputRadioContext { private readonly InputRadioContext? _parentContext; @@ -20,6 +20,11 @@ public class InputRadioContext /// 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. /// @@ -31,17 +36,20 @@ public class InputRadioContext /// 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; } diff --git a/src/Components/Web/src/Forms/InputRadioGroup.cs b/src/Components/Web/src/Forms/InputRadioGroup.cs index 4029abab1b76..0e30fd483e09 100644 --- a/src/Components/Web/src/Forms/InputRadioGroup.cs +++ b/src/Components/Web/src/Forms/InputRadioGroup.cs @@ -31,10 +31,11 @@ public class InputRadioGroup : InputBase /// protected override void OnParametersSet() { - var changeEventCallback = EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString); 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, changeEventCallback); + _context = new InputRadioContext(CascadedContext, groupName, CurrentValue, fieldClass, changeEventCallback); } /// @@ -42,15 +43,11 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) { Debug.Assert(_context != null); - builder.OpenElement(0, "div"); - builder.AddMultipleAttributes(1, AdditionalAttributes); - builder.AddAttribute(2, "class", CssClass); - builder.OpenComponent>(3); - builder.AddAttribute(4, "IsFixed", true); - builder.AddAttribute(5, "Value", _context); - builder.AddAttribute(6, "ChildContent", ChildContent); + builder.OpenComponent>(2); + builder.AddAttribute(3, "IsFixed", true); + builder.AddAttribute(4, "Value", _context); + builder.AddAttribute(5, "ChildContent", ChildContent); builder.CloseComponent(); - builder.CloseElement(); } /// 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/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs index d055758abfe7..15495dc7da87 100644 --- a/src/Components/test/E2ETest/Tests/FormsTest.cs +++ b/src/Components/test/E2ETest/Tests/FormsTest.cs @@ -305,8 +305,7 @@ public void InputCheckboxInteractsWithEditContext() public void InputRadioGroupWithoutNameInteractsWithEditContext() { var appElement = MountTypicalValidationComponent(); - var airlineGroup = appElement.FindElement(By.Id("airline-group")); - var airlineInputs = airlineGroup.FindElements(By.TagName("input")); + 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); @@ -321,13 +320,13 @@ public void InputRadioGroupWithoutNameInteractsWithEditContext() Browser.True(() => unknownAirlineInput.GetAttribute("extra").Equals("additional")); // Validates on edit - Browser.Equal("valid", () => airlineGroup.GetAttribute("class")); + Assert.All(airlineInputs, i => Browser.Equal("valid", () => i.GetAttribute("class"))); bestAirlineInput.Click(); - Browser.Equal("modified valid", () => airlineGroup.GetAttribute("class")); + Assert.All(airlineInputs, i => Browser.Equal("modified valid", () => i.GetAttribute("class"))); // Can become invalid unknownAirlineInput.Click(); - Browser.Equal("modified invalid", () => airlineGroup.GetAttribute("class")); + Assert.All(airlineInputs, i => Browser.Equal("modified invalid", () => i.GetAttribute("class"))); Browser.Equal(new[] { "Pick a valid airline." }, messagesAccessor); } @@ -336,10 +335,9 @@ public void InputRadioGroupsWithNamesNestedInteractWithEditContext() { var appElement = MountTypicalValidationComponent(); var submitButton = appElement.FindElement(By.CssSelector("button[type=submit]")); - var countryGroup = appElement.FindElement(By.Id("country-group")); - var colorGroup = appElement.FindElement(By.Id("color-group")); - var countryInputs = countryGroup.FindElements(By.Name("country")); - var colorInputs = colorGroup.FindElements(By.Name("color")); + 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); @@ -350,19 +348,19 @@ public void InputRadioGroupsWithNamesNestedInteractWithEditContext() Assert.All(colorInputs, i => Browser.False(() => i.Selected)); // Invalidates on submit - Browser.Equal("valid", () => countryGroup.GetAttribute("class")); - Browser.Equal("valid", () => colorGroup.GetAttribute("class")); + Assert.All(countryInputs, i => Browser.Equal("valid", () => i.GetAttribute("class"))); + Assert.All(colorInputs, i => Browser.Equal("valid", () => i.GetAttribute("class"))); submitButton.Click(); - Browser.Equal("invalid", () => countryGroup.GetAttribute("class")); - Browser.Equal("invalid", () => colorGroup.GetAttribute("class")); + 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(); - Browser.Equal("modified valid", () => countryGroup.GetAttribute("class")); - Browser.Equal("invalid", () => colorGroup.GetAttribute("class")); + Assert.All(countryInputs, i => Browser.Equal("modified valid", () => i.GetAttribute("class"))); + Assert.All(colorInputs, i => Browser.Equal("invalid", () => i.GetAttribute("class"))); colorInputs.First().Click(); - Browser.Equal("modified valid", () => colorGroup.GetAttribute("class")); + Assert.All(colorInputs, i => Browser.Equal("modified valid", () => i.GetAttribute("class"))); } [Fact] diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor index 25be43570fdc..4cf0a9d80f42 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor @@ -41,7 +41,7 @@ @person.TicketClass

- + Airline:
@foreach (var airline in (Airline[])Enum.GetValues(typeof(Airline))) @@ -54,8 +54,8 @@

Pick one color and one country: - - + + red
japan
green