diff --git a/eng/scripts/CodeCheck.ps1 b/eng/scripts/CodeCheck.ps1 index c3e02ff55716..73691561e618 100644 --- a/eng/scripts/CodeCheck.ps1 +++ b/eng/scripts/CodeCheck.ps1 @@ -213,7 +213,7 @@ try { } } # Check for changes in Unshipped in servicing branches - if ($targetBranch -like 'release*' -and $targetBranch -notlike '*preview*' -and $file -like '*PublicAPI.Unshipped.txt') { + if ($targetBranch -like 'release*' -and $targetBranch -notlike '*preview*' -and $targetBranch -notlike '*rc*' -and $file -like '*PublicAPI.Unshipped.txt') { $changedAPIBaselines.Add($file) } } diff --git a/src/Components/Components.slnf b/src/Components/Components.slnf index 14878520e4d5..7bd92c90828f 100644 --- a/src/Components/Components.slnf +++ b/src/Components/Components.slnf @@ -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", @@ -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", @@ -96,6 +102,7 @@ "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", @@ -103,6 +110,7 @@ "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", @@ -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" ] } -} +} \ No newline at end of file diff --git a/src/Components/Web/src/Forms/InputExtensions.cs b/src/Components/Web/src/Forms/InputExtensions.cs index f0065f0611ff..32f8b46c838d 100644 --- a/src/Components/Web/src/Forms/InputExtensions.cs +++ b/src/Components/Web/src/Forms/InputExtensions.cs @@ -16,23 +16,61 @@ internal static class InputExtensions { try { - if (BindConverter.TryConvertTo(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(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(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(string? value, out TValue result) + { + if (string.IsNullOrEmpty(value)) + { + result = default!; + return true; + } + + return TryConvertToBool(value, out result); + } } } diff --git a/src/Components/Web/src/Forms/InputSelect.cs b/src/Components/Web/src/Forms/InputSelect.cs index 477c8d69326b..cc4ec970b571 100644 --- a/src/Components/Web/src/Forms/InputSelect.cs +++ b/src/Components/Web/src/Forms/InputSelect.cs @@ -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); + /// + 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(value, CultureInfo.CurrentCulture, out var result) diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 3463104ba853..5f28157c5bb1 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -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.OnParametersSet() -> void abstract Microsoft.AspNetCore.Components.RenderTree.WebRenderer.AttachRootComponentToBrowser(int componentId, string! domElementSelector) -> void +override Microsoft.AspNetCore.Components.Forms.InputSelect.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 diff --git a/src/Components/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs index d1bbdf36f910..c95813707567 100644 --- a/src/Components/test/E2ETest/Tests/FormsTest.cs +++ b/src/Components/test/E2ETest/Tests/FormsTest.cs @@ -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() { @@ -521,6 +549,42 @@ public void InputRadioGroupsWithNamesNestedInteractWithEditContext() IReadOnlyCollection 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 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() { diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor index 458759f9ab62..3fb9217a5fbd 100644 --- a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor @@ -70,6 +70,14 @@ @string.Join(", ", person.HostileStrings)

+

+ T/F: 77 + 33 = 100
+ + + + + +

Airline: @@ -96,6 +104,13 @@

+

+ T/F: 7 * 3 = 21
+ + true
+ false
+
+

Socks color:

@@ -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; }