Skip to content

Binding support for 'bool' values with InputRadioGroup and InputSelect #35318

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Aug 19, 2021
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions src/Components/Components.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -46,23 +46,27 @@
"src\\Components\\test\\E2ETestMigration\\Microsoft.AspNetCore.Components.Migration.E2ETests.csproj",
"src\\Components\\test\\E2ETest\\Microsoft.AspNetCore.Components.E2ETests.csproj",
"src\\Components\\test\\testassets\\BasicTestApp\\BasicTestApp.csproj",
"src\\Components\\test\\testassets\\ComponentsApp.App\\ComponentsApp.App.csproj",
"src\\Components\\test\\testassets\\ComponentsApp.Server\\ComponentsApp.Server.csproj",
"src\\Components\\test\\testassets\\GlobalizationWasmApp\\GlobalizationWasmApp.csproj",
"src\\Components\\test\\testassets\\LazyTestContentPackage\\LazyTestContentPackage.csproj",
"src\\Components\\test\\testassets\\TestContentPackage\\TestContentPackage.csproj",
"src\\Components\\test\\testassets\\TestServer\\Components.TestServer.csproj",
"src\\DataProtection\\Abstractions\\src\\Microsoft.AspNetCore.DataProtection.Abstractions.csproj",
"src\\DataProtection\\Cryptography.Internal\\src\\Microsoft.AspNetCore.Cryptography.Internal.csproj",
"src\\DataProtection\\Cryptography.KeyDerivation\\src\\Microsoft.AspNetCore.Cryptography.KeyDerivation.csproj",
"src\\DataProtection\\DataProtection\\src\\Microsoft.AspNetCore.DataProtection.csproj",
"src\\DataProtection\\Extensions\\src\\Microsoft.AspNetCore.DataProtection.Extensions.csproj",
"src\\DefaultBuilder\\src\\Microsoft.AspNetCore.csproj",
"src\\Extensions\\Features\\src\\Microsoft.Extensions.Features.csproj",
"src\\Features\\JsonPatch\\src\\Microsoft.AspNetCore.JsonPatch.csproj",
"src\\FileProviders\\Embedded\\src\\Microsoft.Extensions.FileProviders.Embedded.csproj",
"src\\Hosting\\Abstractions\\src\\Microsoft.AspNetCore.Hosting.Abstractions.csproj",
"src\\Hosting\\Hosting\\src\\Microsoft.AspNetCore.Hosting.csproj",
"src\\Hosting\\Server.Abstractions\\src\\Microsoft.AspNetCore.Hosting.Server.Abstractions.csproj",
"src\\Html.Abstractions\\src\\Microsoft.AspNetCore.Html.Abstractions.csproj",
"src\\Http\\Authentication.Abstractions\\src\\Microsoft.AspNetCore.Authentication.Abstractions.csproj",
"src\\Http\\Authentication.Core\\src\\Microsoft.AspNetCore.Authentication.Core.csproj",
"src\\Extensions\\Features\\src\\Microsoft.Extensions.Features.csproj",
"src\\Http\\Headers\\src\\Microsoft.Net.Http.Headers.csproj",
"src\\Http\\Http.Abstractions\\src\\Microsoft.AspNetCore.Http.Abstractions.csproj",
"src\\Http\\Http.Extensions\\src\\Microsoft.AspNetCore.Http.Extensions.csproj",
Expand All @@ -79,6 +83,8 @@
"src\\Identity\\Extensions.Stores\\src\\Microsoft.Extensions.Identity.Stores.csproj",
"src\\Identity\\UI\\src\\Microsoft.AspNetCore.Identity.UI.csproj",
"src\\JSInterop\\Microsoft.JSInterop\\src\\Microsoft.JSInterop.csproj",
"src\\Localization\\Abstractions\\src\\Microsoft.Extensions.Localization.Abstractions.csproj",
"src\\Localization\\Localization\\src\\Microsoft.Extensions.Localization.csproj",
"src\\Middleware\\CORS\\src\\Microsoft.AspNetCore.Cors.csproj",
"src\\Middleware\\Diagnostics.Abstractions\\src\\Microsoft.AspNetCore.Diagnostics.Abstractions.csproj",
"src\\Middleware\\Diagnostics\\src\\Microsoft.AspNetCore.Diagnostics.csproj",
Expand All @@ -96,13 +102,15 @@
"src\\Mvc\\Mvc.Core\\src\\Microsoft.AspNetCore.Mvc.Core.csproj",
"src\\Mvc\\Mvc.Cors\\src\\Microsoft.AspNetCore.Mvc.Cors.csproj",
"src\\Mvc\\Mvc.DataAnnotations\\src\\Microsoft.AspNetCore.Mvc.DataAnnotations.csproj",
"src\\Mvc\\Mvc.Formatters.Json\\src\\Microsoft.AspNetCore.Mvc.Formatters.Json.csproj",
"src\\Mvc\\Mvc.Localization\\src\\Microsoft.AspNetCore.Mvc.Localization.csproj",
"src\\Mvc\\Mvc.NewtonsoftJson\\src\\Microsoft.AspNetCore.Mvc.NewtonsoftJson.csproj",
"src\\Mvc\\Mvc.RazorPages\\src\\Microsoft.AspNetCore.Mvc.RazorPages.csproj",
"src\\Mvc\\Mvc.Razor\\src\\Microsoft.AspNetCore.Mvc.Razor.csproj",
"src\\Mvc\\Mvc.TagHelpers\\src\\Microsoft.AspNetCore.Mvc.TagHelpers.csproj",
"src\\Mvc\\Mvc.ViewFeatures\\src\\Microsoft.AspNetCore.Mvc.ViewFeatures.csproj",
"src\\Mvc\\Mvc\\src\\Microsoft.AspNetCore.Mvc.csproj",
"src\\ObjectPool\\src\\Microsoft.Extensions.ObjectPool.csproj",
"src\\Razor\\Razor.Runtime\\src\\Microsoft.AspNetCore.Razor.Runtime.csproj",
"src\\Razor\\Razor\\src\\Microsoft.AspNetCore.Razor.csproj",
"src\\Security\\Authentication\\Cookies\\src\\Microsoft.AspNetCore.Authentication.Cookies.csproj",
Expand All @@ -128,7 +136,8 @@
"src\\SignalR\\common\\SignalR.Common\\src\\Microsoft.AspNetCore.SignalR.Common.csproj",
"src\\SignalR\\server\\Core\\src\\Microsoft.AspNetCore.SignalR.Core.csproj",
"src\\SignalR\\server\\SignalR\\src\\Microsoft.AspNetCore.SignalR.csproj",
"src\\Testing\\src\\Microsoft.AspNetCore.Testing.csproj"
"src\\Testing\\src\\Microsoft.AspNetCore.Testing.csproj",
"src\\WebEncoders\\src\\Microsoft.Extensions.WebEncoders.csproj"
]
}
}
}
2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.server.js

Large diffs are not rendered by default.

52 changes: 45 additions & 7 deletions src/Components/Web/src/Forms/InputExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,61 @@ internal static class InputExtensions
{
try
{
if (BindConverter.TryConvertTo<TValue>(value, CultureInfo.CurrentCulture, out var parsedValue))
// We special-case bool values because BindConverter reserves bool conversion for conditional attributes.
if (typeof(TValue) == typeof(bool))
{
if (TryConvertToBool(value, out result))
{
validationErrorMessage = null;
return true;
}
}
else if (typeof(TValue) == typeof(bool?))
{
if (TryConvertToNullableBool(value, out result))
{
validationErrorMessage = null;
return true;
}
}
else if (BindConverter.TryConvertTo<TValue>(value, CultureInfo.CurrentCulture, out var parsedValue))
{
result = parsedValue;
validationErrorMessage = null;
return true;
}
else
{
result = default;
validationErrorMessage = $"The {input.DisplayName ?? input.FieldIdentifier.FieldName} field is not valid.";
return false;
}

result = default;
validationErrorMessage = $"The {input.DisplayName ?? input.FieldIdentifier.FieldName} field is not valid.";
return false;
}
catch (InvalidOperationException ex)
{
throw new InvalidOperationException($"{input.GetType()} does not support the type '{typeof(TValue)}'.", ex);
}
}

private static bool TryConvertToBool<TValue>(string? value, out TValue result)
{
if (bool.TryParse(value, out var @bool))
{
result = (TValue)(object)@bool;
return true;
}

result = default!;
return false;
}

private static bool TryConvertToNullableBool<TValue>(string? value, out TValue result)
{
if (string.IsNullOrEmpty(value))
{
result = default!;
return true;
}

return TryConvertToBool(value, out result);
}
}
}
16 changes: 16 additions & 0 deletions src/Components/Web/src/Forms/InputSelect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,22 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage)
=> this.TryParseSelectableValueFromString(value, out result, out validationErrorMessage);

/// <inheritdoc />
protected override string? FormatValueAsString(TValue? value)
{
// We special-case bool values because BindConverter reserves bool conversion for conditional attributes.
if (typeof(TValue) == typeof(bool))
{
return (bool)(object)value! ? "true" : "false";
}
else if (typeof(TValue) == typeof(bool?))
{
return value is not null && (bool)(object)value ? "true" : "false";
}

return base.FormatValueAsString(value);
}

private void SetCurrentValueAsStringArray(string?[]? value)
{
CurrentValue = BindConverter.TryConvertTo<TValue>(value, CultureInfo.CurrentCulture, out var result)
Expand Down
1 change: 1 addition & 0 deletions src/Components/Web/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ Microsoft.AspNetCore.Components.Web.PageTitle.ChildContent.set -> void
Microsoft.AspNetCore.Components.Web.PageTitle.PageTitle() -> void
override Microsoft.AspNetCore.Components.Forms.InputDate<TValue>.OnParametersSet() -> void
abstract Microsoft.AspNetCore.Components.RenderTree.WebRenderer.AttachRootComponentToBrowser(int componentId, string! domElementSelector) -> void
override Microsoft.AspNetCore.Components.Forms.InputSelect<TValue>.FormatValueAsString(TValue? value) -> string?
override Microsoft.AspNetCore.Components.RenderTree.WebRenderer.Dispose(bool disposing) -> void
override Microsoft.AspNetCore.Components.Routing.FocusOnNavigate.OnAfterRenderAsync(bool firstRender) -> System.Threading.Tasks.Task!
override Microsoft.AspNetCore.Components.Routing.FocusOnNavigate.OnParametersSet() -> void
Expand Down
64 changes: 64 additions & 0 deletions src/Components/test/E2ETest/Tests/FormsTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,34 @@ public void InputSelectInteractsWithEditContext()
Browser.Equal(new[] { "The TicketClass field is not valid." }, messagesAccessor);
}

[Fact]
public void InputSelectInteractsWithEditContext_BoolValues()
{
var appElement = MountTypicalValidationComponent();
var ticketClassInput = new SelectElement(appElement.FindElement(By.ClassName("select-bool-values")).FindElement(By.TagName("select")));
var select = ticketClassInput.WrappedElement;
var messagesAccessor = CreateValidationMessagesAccessor(appElement);

// Invalidates on edit
Browser.Equal("valid", () => select.GetAttribute("class"));
ticketClassInput.SelectByText("true");
Browser.Equal("modified invalid", () => select.GetAttribute("class"));
Browser.Equal(new[] { "77 + 33 = 100 is a false statement, unfortunately." }, messagesAccessor);

// Nullable conversion can fail
ticketClassInput.SelectByText("(select)");
Browser.Equal("modified invalid", () => select.GetAttribute("class"));
Browser.Equal(new[]
{
"77 + 33 = 100 is a false statement, unfortunately.",
"The IsSelectMathStatementTrue field is not valid."
}, messagesAccessor);

// Can become valid
ticketClassInput.SelectByText("false");
Browser.Equal("modified valid", () => select.GetAttribute("class"));
}

[Fact]
public void InputSelectInteractsWithEditContext_MultipleAttribute()
{
Expand Down Expand Up @@ -521,6 +549,42 @@ public void InputRadioGroupsWithNamesNestedInteractWithEditContext()
IReadOnlyCollection<IWebElement> FindColorInputs() => group.FindElements(By.Name("color"));
}

[Fact]
public void InputRadioGroupWithBoolValuesInteractsWithEditContext()
{
var appElement = MountTypicalValidationComponent();
var messagesAccessor = CreateValidationMessagesAccessor(appElement);

// Validate selected inputs
Browser.False(() => FindTrueInput().Selected);
Browser.True(() => FindFalseInput().Selected);

// Validates on edit
Browser.Equal("valid", () => FindTrueInput().GetAttribute("class"));
Browser.Equal("valid", () => FindFalseInput().GetAttribute("class"));

FindTrueInput().Click();

Browser.Equal("modified valid", () => FindTrueInput().GetAttribute("class"));
Browser.Equal("modified valid", () => FindFalseInput().GetAttribute("class"));

// Can become invalid
FindFalseInput().Click();

Browser.Equal("modified invalid", () => FindTrueInput().GetAttribute("class"));
Browser.Equal("modified invalid", () => FindFalseInput().GetAttribute("class"));
Browser.Equal(new[] { "7 * 3 = 21 is a true statement." }, messagesAccessor);

IReadOnlyCollection<IWebElement> FindInputs()
=> appElement.FindElement(By.ClassName("radio-group-bool-values")).FindElements(By.TagName("input"));

IWebElement FindTrueInput()
=> FindInputs().First(i => string.Equals("True", i.GetAttribute("value")));

IWebElement FindFalseInput()
=> FindInputs().First(i => string.Equals("False", i.GetAttribute("value")));
}

[Fact]
public void CanWireUpINotifyPropertyChangedToEditContext()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@
</InputSelect>
<span>@string.Join(", ", person.HostileStrings)</span>
</p>
<p class="select-bool-values">
T/F: 77 + 33 = 100<br>
<InputSelect @bind-Value="person.IsSelectMathStatementTrue">
<option>(select)</option>
<option value="true">true</option>
<option value="false">false</option>
</InputSelect>
</p>
<p class="airline">
<InputRadioGroup @bind-Value="person.Airline">
Airline:
Expand All @@ -96,6 +104,13 @@
</InputRadioGroup>
</InputRadioGroup>
</p>
<p class="radio-group-bool-values">
T/F: 7 * 3 = 21<br>
<InputRadioGroup @bind-Value="person.IsRadioMathStatementTrue">
<InputRadio Value="true" />true<br>
<InputRadio Value="false" />false<br>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing that if the code here was Value="@true" and Value="@false" it would still work the same, would it?

If I'm reading the code here correctly, <InputRadio Value="true" /> will be an InputRadio<string> rather than an InputRadio<bool>. Does it make any difference if the developer makes it into an InputRadio<bool>?

I think it doesn't make any difference because the binding machinery is really on the InputRadioGroup which will be an InputRadioGroup<bool> in both cases.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since Value is a parameter of InputRadio, not an HTML attribute, isn't 'true' interpreted as the value true rather than the string "true"? In other words, I think true and @true are equivalent here (correct me if I misinterpreted your comment).

That said, the <InputSelect> case is a little weird in exactly this regard:

<InputSelect @bind-Value="ignoreTheTypeOfThis">
    <!-- We can set the value attribute to an enum, because the bind formatter properly serializes it -->
    <option value="@SomeEnum.Choice">Choice</option> <!-- Good -->

    <!-- But we can't do this with a bool, because now 'value' is treated as a conditional attribute -->
    <option value="@true">true</option> <!-- Bad! -->

    <!-- So, we have to use a string -->
    <option value="true">true</option> <!-- Good? -->
</InputSelect>

</InputRadioGroup>
</p>
<p class="socks">
Socks color: <InputText @bind-Value="person.SocksColor" />
</p>
Expand Down Expand Up @@ -188,6 +203,12 @@
[Required, EnumDataType(typeof(Country))]
public Country? Country { get; set; } = null;

[Required, Range(typeof(bool), "false", "false", ErrorMessage = "77 + 33 = 100 is a false statement, unfortunately.")]
public bool? IsSelectMathStatementTrue { get; set; } = null;

[Required, Range(typeof(bool), "true", "true", ErrorMessage = "7 * 3 = 21 is a true statement.")]
public bool IsRadioMathStatementTrue { get; set; } = false;

[Required, StringLength(10), CustomValidationClassName(Valid = "valid-socks", Invalid = "invalid-socks")]
public string SocksColor { get; set; }

Expand Down