From 5af8e170bc11aa189f7be0aab777b1bdfb76a431 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Tue, 4 Jun 2019 17:31:01 -0700 Subject: [PATCH] Add support for TypeConverter (#10730) * Add support for TypeConverter Fixes: #8493 Fixes: #9632 Fixes: #9339 Fixes: #8385 Fixes: 10077 This fix adds support for type converters as well as a few other minor things we were missing from binding. The key thing about supporting conversions is that we new can support arbitrary types with `@bind`. This means you can use it with generics, which is something many users have tried. Along with type converters we get Guid and TimeSpan from the BCL. The BCL also includes converters for types we're less interested in like `short`. * Use correct NumberStyles * Fix culture * Core check --- .../targets/BuiltInBclLinkerDescriptor.xml | 4 + ...ft.AspNetCore.Components.netstandard2.0.cs | 3 +- .../EventCallbackFactoryBinderExtensions.cs | 341 ++++++++++++++---- ...ventCallbackFactoryBinderExtensionsTest.cs | 152 ++++++++ src/Components/test/E2ETest/Tests/BindTest.cs | 44 +++ .../BasicTestApp/BindCasesComponent.razor | 70 ++-- .../BasicTestApp/BindGenericComponent.razor | 9 + 7 files changed, 519 insertions(+), 104 deletions(-) create mode 100644 src/Components/test/testassets/BasicTestApp/BindGenericComponent.razor diff --git a/src/Components/Blazor/Build/src/targets/BuiltInBclLinkerDescriptor.xml b/src/Components/Blazor/Build/src/targets/BuiltInBclLinkerDescriptor.xml index 4b442b1bb8..32533df8ca 100644 --- a/src/Components/Blazor/Build/src/targets/BuiltInBclLinkerDescriptor.xml +++ b/src/Components/Blazor/Build/src/targets/BuiltInBclLinkerDescriptor.xml @@ -13,5 +13,9 @@ + + + + diff --git a/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs b/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs index 382346efce..7bdacfbc4b 100644 --- a/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs +++ b/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs @@ -155,6 +155,7 @@ namespace Microsoft.AspNetCore.Components public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, int existingValue) { throw null; } public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, long existingValue) { throw null; } public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, bool? existingValue) { throw null; } + public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, System.DateTime? existingValue) { throw null; } public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, decimal? existingValue) { throw null; } public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, double? existingValue) { throw null; } public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, int? existingValue) { throw null; } @@ -162,7 +163,7 @@ namespace Microsoft.AspNetCore.Components public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, float? existingValue) { throw null; } public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, float existingValue) { throw null; } public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, string existingValue) { throw null; } - public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, T existingValue) where T : struct, System.Enum { throw null; } + public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, T existingValue) { throw null; } } public static partial class EventCallbackFactoryUIEventArgsExtensions { diff --git a/src/Components/Components/src/EventCallbackFactoryBinderExtensions.cs b/src/Components/Components/src/EventCallbackFactoryBinderExtensions.cs index ba0a23dd0a..16fe53ad26 100644 --- a/src/Components/Components/src/EventCallbackFactoryBinderExtensions.cs +++ b/src/Components/Components/src/EventCallbackFactoryBinderExtensions.cs @@ -2,13 +2,27 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Diagnostics; using System.Globalization; +using System.Reflection; namespace Microsoft.AspNetCore.Components { /// /// Contains extension methods for two-way binding using . For internal use only. /// + // + // NOTE: for number parsing, the HTML5 spec dictates that the DOM will represent + // number values as floating point numbers using `.` as the period separator. This is NOT culture senstive. + // Put another way, the user might see `,` as their decimal separator, but the value available in events + // to JS code is always simpilar to what .NET parses with InvariantCulture. + // + // See: https://www.w3.org/TR/html5/sec-forms.html#number-state-typenumber + // See: https://www.w3.org/TR/html5/infrastructure.html#valid-floating-point-number + // + // For now we're not necessarily handling this correctly since we parse the same way for number and text. public static class EventCallbackFactoryBinderExtensions { private delegate bool BindConverter(object obj, out T value); @@ -53,7 +67,7 @@ namespace Microsoft.AspNetCore.Components return false; } - if (!int.TryParse(text, out var converted)) + if (!int.TryParse(text, NumberStyles.Integer, CultureInfo.CurrentCulture, out var converted)) { value = default; return false; @@ -72,7 +86,7 @@ namespace Microsoft.AspNetCore.Components return true; } - if (!int.TryParse(text, out var converted)) + if (!int.TryParse(text, NumberStyles.Integer, CultureInfo.CurrentCulture, out var converted)) { value = default; return false; @@ -94,7 +108,7 @@ namespace Microsoft.AspNetCore.Components return false; } - if (!long.TryParse(text, out var converted)) + if (!long.TryParse(text, NumberStyles.Integer, CultureInfo.CurrentCulture, out var converted)) { value = default; return false; @@ -113,7 +127,7 @@ namespace Microsoft.AspNetCore.Components return true; } - if (!long.TryParse(text, out var converted)) + if (!long.TryParse(text, NumberStyles.Integer, CultureInfo.CurrentCulture, out var converted)) { value = default; return false; @@ -135,7 +149,7 @@ namespace Microsoft.AspNetCore.Components return false; } - if (!float.TryParse(text, out var converted)) + if (!float.TryParse(text, NumberStyles.Number, CultureInfo.CurrentCulture, out var converted)) { value = default; return false; @@ -154,7 +168,7 @@ namespace Microsoft.AspNetCore.Components return true; } - if (!float.TryParse(text, out var converted)) + if (!float.TryParse(text, NumberStyles.Number, CultureInfo.CurrentCulture, out var converted)) { value = default; return false; @@ -176,7 +190,7 @@ namespace Microsoft.AspNetCore.Components return false; } - if (!double.TryParse(text, out var converted)) + if (!double.TryParse(text, NumberStyles.Number, CultureInfo.CurrentCulture, out var converted)) { value = default; return false; @@ -195,7 +209,7 @@ namespace Microsoft.AspNetCore.Components return true; } - if (!double.TryParse(text, out var converted)) + if (!double.TryParse(text, NumberStyles.Number, CultureInfo.CurrentCulture, out var converted)) { value = default; return false; @@ -217,7 +231,7 @@ namespace Microsoft.AspNetCore.Components return false; } - if (!decimal.TryParse(text, out var converted)) + if (!decimal.TryParse(text, NumberStyles.Number, CultureInfo.CurrentCulture, out var converted)) { value = default; return false; @@ -236,7 +250,7 @@ namespace Microsoft.AspNetCore.Components return true; } - if (!decimal.TryParse(text, out var converted)) + if (!decimal.TryParse(text, NumberStyles.Number, CultureInfo.CurrentCulture, out var converted)) { value = default; return false; @@ -246,28 +260,83 @@ namespace Microsoft.AspNetCore.Components return true; } - private static class EnumConverter where T : struct, Enum + private static BindConverter ConvertToDateTime = ConvertToDateTimeCore; + private static BindConverter ConvertToNullableDateTime = ConvertToNullableDateTimeCore; + + private static bool ConvertToDateTimeCore(object obj, out DateTime value) { - public static readonly BindConverter Convert = ConvertCore; - - public static bool ConvertCore(object obj, out T value) + var text = (string)obj; + if (string.IsNullOrEmpty(text)) { - var text = (string)obj; - if (string.IsNullOrEmpty(text)) - { - value = default; - return true; - } + value = default; + return false; + } - if (!Enum.TryParse(text, out var converted)) - { - value = default; - return false; - } + if (!DateTime.TryParse(text, CultureInfo.CurrentCulture, DateTimeStyles.None, out var converted)) + { + value = default; + return false; + } - value = converted; + value = converted; + return true; + } + + private static bool ConvertToNullableDateTimeCore(object obj, out DateTime? value) + { + var text = (string)obj; + if (string.IsNullOrEmpty(text)) + { + value = default; return true; } + + if (!DateTime.TryParse(text, CultureInfo.CurrentCulture, DateTimeStyles.None, out var converted)) + { + value = default; + return false; + } + + value = converted; + return true; + } + + private static bool ConvertToEnum(object obj, out T value) where T : struct, Enum + { + var text = (string)obj; + if (string.IsNullOrEmpty(text)) + { + value = default; + return true; + } + + if (!Enum.TryParse(text, out var converted)) + { + value = default; + return false; + } + + value = converted; + return true; + } + + private static bool ConvertToNullableEnum(object obj, out Nullable value) where T : struct, Enum + { + var text = (string)obj; + if (string.IsNullOrEmpty(text)) + { + value = default; + return true; + } + + if (!Enum.TryParse(text, out var converted)) + { + value = default; + return false; + } + + value = converted; + return true; } /// @@ -284,7 +353,6 @@ namespace Microsoft.AspNetCore.Components Action setter, string existingValue) { - ; return CreateBinderCore(factory, receiver, setter, ConvertToString); } @@ -489,15 +557,6 @@ namespace Microsoft.AspNetCore.Components Action setter, decimal? existingValue) { - Func converter = (obj) => - { - if (decimal.TryParse((string)obj, out var value)) - { - return value; - } - - return null; - }; return CreateBinderCore(factory, receiver, setter, ConvertToNullableDecimal); } @@ -515,28 +574,24 @@ namespace Microsoft.AspNetCore.Components Action setter, DateTime existingValue) { - // Avoiding CreateBinderCore so we can avoid an extra allocating lambda - // when a format is used. - Action callback = (e) => - { - DateTime value = default; - var converted = false; - try - { - value = ConvertDateTime(e.Value, format: null); - converted = true; - } - catch - { - } + return CreateBinderCore(factory, receiver, setter, ConvertToDateTime); + } - // See comments in CreateBinderCore - if (converted) - { - setter(value); - } - }; - return factory.Create(receiver, callback); + /// + /// For internal use only. + /// + /// + /// + /// + /// + /// + public static EventCallback CreateBinder( + this EventCallbackFactory factory, + object receiver, + Action setter, + DateTime? existingValue) + { + return CreateBinderCore(factory, receiver, setter, ConvertToNullableDateTime); } /// @@ -577,6 +632,23 @@ namespace Microsoft.AspNetCore.Components } }; return factory.Create(receiver, callback); + + static DateTime ConvertDateTime(object obj, string format) + { + var text = (string)obj; + if (string.IsNullOrEmpty(text)) + { + return default; + } + else if (format != null && DateTime.TryParseExact(text, format, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var value)) + { + return value; + } + else + { + return DateTime.Parse(text); + } + } } /// @@ -592,26 +664,9 @@ namespace Microsoft.AspNetCore.Components this EventCallbackFactory factory, object receiver, Action setter, - T existingValue) where T : struct, Enum + T existingValue) { - return CreateBinderCore(factory, receiver, setter, EnumConverter.Convert); - } - - private static DateTime ConvertDateTime(object obj, string format) - { - var text = (string)obj; - if (string.IsNullOrEmpty(text)) - { - return default; - } - else if (format != null && DateTime.TryParseExact(text, format, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var value)) - { - return value; - } - else - { - return DateTime.Parse(text); - } + return CreateBinderCore(factory, receiver, setter, BinderConverterCache.Get()); } private static EventCallback CreateBinderCore( @@ -642,5 +697,139 @@ namespace Microsoft.AspNetCore.Components }; return factory.Create(receiver, callback); } + + // We can't rely on generics + static to cache here unfortunately. That would require us to overload + // CreateBinder on T : struct AND T : class, which is not allowed. + private static class BinderConverterCache + { + private readonly static ConcurrentDictionary _cache = new ConcurrentDictionary(); + + private static MethodInfo _convertToEnum; + private static MethodInfo _convertToNullableEnum; + + public static BindConverter Get() + { + if (!_cache.TryGetValue(typeof(T), out var converter)) + { + // We need to replicate all of the primitive cases that we handle here so that they will behave the same way. + // The result will be cached. + if (typeof(T) == typeof(string)) + { + converter = ConvertToString; + } + else if (typeof(T) == typeof(bool)) + { + converter = ConvertToBool; + } + else if (typeof(T) == typeof(bool?)) + { + converter = ConvertToNullableBool; + } + else if (typeof(T) == typeof(int)) + { + converter = ConvertToInt; + } + else if (typeof(T) == typeof(int?)) + { + converter = ConvertToNullableInt; + } + else if (typeof(T) == typeof(long)) + { + converter = ConvertToLong; + } + else if (typeof(T) == typeof(long?)) + { + converter = ConvertToNullableLong; + } + else if (typeof(T) == typeof(float)) + { + converter = ConvertToFloat; + } + else if (typeof(T) == typeof(float?)) + { + converter = ConvertToNullableFloat; + } + else if (typeof(T) == typeof(double)) + { + converter = ConvertToDouble; + } + else if (typeof(T) == typeof(double?)) + { + converter = ConvertToNullableDouble; + } + else if (typeof(T) == typeof(decimal)) + { + converter = ConvertToDecimal; + } + else if (typeof(T) == typeof(decimal?)) + { + converter = ConvertToNullableDecimal; + } + else if (typeof(T) == typeof(DateTime)) + { + converter = ConvertToDateTime; + } + else if (typeof(T) == typeof(DateTime?)) + { + converter = ConvertToNullableDateTime; + } + else if (typeof(T).IsEnum) + { + // We have to deal invoke this dynamically to work around the type constraint on Enum.TryParse. + var method = _convertToEnum ??= typeof(EventCallbackFactoryBinderExtensions).GetMethod(nameof(ConvertToEnum), BindingFlags.NonPublic | BindingFlags.Static); + converter = method.MakeGenericMethod(typeof(T)).CreateDelegate(typeof(BindConverter), target: null); + } + else if (Nullable.GetUnderlyingType(typeof(T)) is Type innerType && innerType.IsEnum) + { + // We have to deal invoke this dynamically to work around the type constraint on Enum.TryParse. + var method = _convertToNullableEnum ??= typeof(EventCallbackFactoryBinderExtensions).GetMethod(nameof(ConvertToNullableEnum), BindingFlags.NonPublic | BindingFlags.Static); + converter = method.MakeGenericMethod(innerType).CreateDelegate(typeof(BindConverter), target: null); + } + else + { + converter = MakeTypeConverterConverter(); + } + + _cache.TryAdd(typeof(T), converter); + } + + return (BindConverter)converter; + } + + private static BindConverter MakeTypeConverterConverter() + { + var typeConverter = TypeDescriptor.GetConverter(typeof(T)); + if (typeConverter == null || !typeConverter.CanConvertFrom(typeof(string))) + { + throw new InvalidOperationException( + $"The type '{typeof(T).FullName}' does not have an associated {typeof(TypeConverter).Name} that supports " + + $"conversion from a string. " + + $"Apply '{typeof(TypeConverterAttribute).Name}' to the type to register a converter."); + } + + return ConvertWithTypeConverter; + + bool ConvertWithTypeConverter(object obj, out T value) + { + var text = (string)obj; + if (string.IsNullOrEmpty(text)) + { + value = default; + return true; + } + + // We intentionally close-over the TypeConverter to cache it. The TypeDescriptor infrastructure is slow. + var converted = typeConverter.ConvertFromString(context: null, CultureInfo.CurrentCulture, text); + if (converted == null) + { + value = default; + return false; + } + + value = (T)converted; + return true; + } + } + } } } diff --git a/src/Components/Components/test/EventCallbackFactoryBinderExtensionsTest.cs b/src/Components/Components/test/EventCallbackFactoryBinderExtensionsTest.cs index 6779401282..84bfc72507 100644 --- a/src/Components/Components/test/EventCallbackFactoryBinderExtensionsTest.cs +++ b/src/Components/Components/test/EventCallbackFactoryBinderExtensionsTest.cs @@ -2,6 +2,8 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.ComponentModel; +using System.Globalization; using System.Threading.Tasks; using Xunit; @@ -344,6 +346,25 @@ namespace Microsoft.AspNetCore.Components Assert.Equal(1, component.Count); } + [Fact] + public async Task CreateBinder_NullableEnum() + { + // Arrange + var value = (AttributeTargets?)AttributeTargets.All; + var component = new EventCountingComponent(); + Action setter = (_) => value = _; + + var binder = EventCallback.Factory.CreateBinder(component, setter, value); + + var expectedValue = AttributeTargets.Class; + + // Act + await binder.InvokeAsync(new UIChangeEventArgs() { Value = expectedValue.ToString(), }); + + Assert.Equal(expectedValue, value); + Assert.Equal(1, component.Count); + } + [Fact] public async Task CreateBinder_DateTime() { @@ -363,6 +384,26 @@ namespace Microsoft.AspNetCore.Components Assert.Equal(1, component.Count); } + [Fact] + public async Task CreateBinder_NullableDateTime() + { + // Arrange + var value = (DateTime?)DateTime.Now; + var component = new EventCountingComponent(); + Action setter = (_) => value = _; + + var binder = EventCallback.Factory.CreateBinder(component, setter, value); + + var expectedValue = new DateTime(2018, 3, 4, 1, 2, 3); + + // Act + await binder.InvokeAsync(new UIChangeEventArgs() { Value = expectedValue.ToString(), }); + + Assert.Equal(expectedValue, value); + Assert.Equal(1, component.Count); + } + + // For now format is only supported by this specific method. [Fact] public async Task CreateBinder_DateTime_Format() { @@ -383,6 +424,80 @@ namespace Microsoft.AspNetCore.Components Assert.Equal(1, component.Count); } + // This uses a type converter + [Fact] + public async Task CreateBinder_Guid() + { + // Arrange + var value = Guid.NewGuid(); + var component = new EventCountingComponent(); + Action setter = (_) => value = _; + + var binder = EventCallback.Factory.CreateBinder(component, setter, value); + + var expectedValue = Guid.NewGuid(); + + // Act + await binder.InvokeAsync(new UIChangeEventArgs() { Value = expectedValue.ToString(), }); + + Assert.Equal(expectedValue, value); + Assert.Equal(1, component.Count); + } + + // This uses a type converter + [Fact] + public async Task CreateBinder_NullableGuid() + { + // Arrange + var value = (Guid?)Guid.NewGuid(); + var component = new EventCountingComponent(); + Action setter = (_) => value = _; + + var binder = EventCallback.Factory.CreateBinder(component, setter, value); + + var expectedValue = Guid.NewGuid(); + + // Act + await binder.InvokeAsync(new UIChangeEventArgs() { Value = expectedValue.ToString(), }); + + Assert.Equal(expectedValue, value); + Assert.Equal(1, component.Count); + } + + [Fact] + public async Task CreateBinder_CustomTypeConverter() + { + // Arrange + var value = new SecretMessage() { Message = "A message", }; + var component = new EventCountingComponent(); + Action setter = (_) => value = _; + + var binder = EventCallback.Factory.CreateBinder(component, setter, value); + + var expectedValue = new SecretMessage() { Message = "TypeConverter may be old, but it still works!", }; + + // Act + await binder.InvokeAsync(new UIChangeEventArgs() { Value = expectedValue.ToString(), }); + + Assert.Equal(expectedValue.Message, value.Message); + Assert.Equal(1, component.Count); + } + + [Fact] + public void CreateBinder_GenericWithoutTypeConverter_Throws() + { + var value = new ClassWithoutTypeConverter(); + var component = new EventCountingComponent(); + Action setter = (_) => value = _; + + var ex = Assert.Throws(() => EventCallback.Factory.CreateBinder(component, setter, value)); + + Assert.Equal( + $"The type '{typeof(ClassWithoutTypeConverter).FullName}' does not have an associated TypeConverter that supports conversion from a string. " + + $"Apply 'TypeConverterAttribute' to the type to register a converter.", + ex.Message); + } + private class EventCountingComponent : IComponent, IHandleEvent { public int Count; @@ -403,5 +518,42 @@ namespace Microsoft.AspNetCore.Components throw new System.NotImplementedException(); } } + + private class ClassWithoutTypeConverter + { + } + + [TypeConverter(typeof(SecretMessageTypeConverter))] + private class SecretMessage + { + public string Message { get; set; } + + public override string ToString() + { + return Message; + } + } + + private class SecretMessageTypeConverter : TypeConverter + { + public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) + { + if (sourceType == typeof(string)) + { + return true; + } + + return false; + } + public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) + { + if (value is string message) + { + return new SecretMessage() { Message = message, }; + } + + return null; + } + } } } diff --git a/src/Components/test/E2ETest/Tests/BindTest.cs b/src/Components/test/E2ETest/Tests/BindTest.cs index 51fcc9ea9d..4931dd786e 100644 --- a/src/Components/test/E2ETest/Tests/BindTest.cs +++ b/src/Components/test/E2ETest/Tests/BindTest.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; using BasicTestApp; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; @@ -538,5 +539,48 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests Browser.Equal("0.011", () => boundValue.Text); Assert.Equal("0.011", mirrorValue.GetAttribute("value")); } + + [Fact] + public void CanBindTextboxGenericInt() + { + var target = Browser.FindElement(By.Id("textbox-generic-int")); + var boundValue = Browser.FindElement(By.Id("textbox-generic-int-value")); + var mirrorValue = Browser.FindElement(By.Id("textbox-generic-int-mirror")); + Assert.Equal("-42", target.GetAttribute("value")); + Assert.Equal("-42", boundValue.Text); + Assert.Equal("-42", mirrorValue.GetAttribute("value")); + + // Modify target; value is not updated because it's not convertable. + target.Clear(); + Browser.Equal("-42", () => boundValue.Text); + Assert.Equal("-42", mirrorValue.GetAttribute("value")); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated + target.SendKeys("42\t"); + Browser.Equal("42", () => boundValue.Text); + Assert.Equal("42", mirrorValue.GetAttribute("value")); + } + + [Fact] + public void CanBindTextboxGenericGuid() + { + var target = Browser.FindElement(By.Id("textbox-generic-guid")); + var boundValue = Browser.FindElement(By.Id("textbox-generic-guid-value")); + var mirrorValue = Browser.FindElement(By.Id("textbox-generic-guid-mirror")); + Assert.Equal("00000000-0000-0000-0000-000000000000", target.GetAttribute("value")); + Assert.Equal("00000000-0000-0000-0000-000000000000", boundValue.Text); + Assert.Equal("00000000-0000-0000-0000-000000000000", mirrorValue.GetAttribute("value")); + + // Modify target; value is not updated because it's not convertable. + target.Clear(); + Browser.Equal("00000000-0000-0000-0000-000000000000", () => boundValue.Text); + Assert.Equal("00000000-0000-0000-0000-000000000000", mirrorValue.GetAttribute("value")); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated + var newValue = Guid.NewGuid().ToString(); + target.SendKeys(newValue + "\t"); + Browser.Equal(newValue, () => boundValue.Text); + Assert.Equal(newValue, mirrorValue.GetAttribute("value")); + } } } diff --git a/src/Components/test/testassets/BasicTestApp/BindCasesComponent.razor b/src/Components/test/testassets/BasicTestApp/BindCasesComponent.razor index 362db2936f..a8b393660b 100644 --- a/src/Components/test/testassets/BasicTestApp/BindCasesComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/BindCasesComponent.razor @@ -90,6 +90,19 @@

+

+ Generic bind (int): + + @textboxGenericIntValue + +

+

+ Generic bind (guid): + + @textboxGenericGuidValue + +

+

Text Area

Initially blank: @@ -138,36 +151,39 @@

@code { - string textboxInitiallyBlankValue = null; - string textboxInitiallyPopulatedValue = "Hello"; +string textboxInitiallyBlankValue = null; +string textboxInitiallyPopulatedValue = "Hello"; - string textAreaInitiallyBlankValue = null; - string textAreaInitiallyPopulatedValue = "Hello"; +string textAreaInitiallyBlankValue = null; +string textAreaInitiallyPopulatedValue = "Hello"; - bool? checkboxInitiallyNullValue = null; - bool checkboxInitiallyUncheckedValue = false; - bool checkboxInitiallyCheckedValue = true; +bool? checkboxInitiallyNullValue = null; +bool checkboxInitiallyUncheckedValue = false; +bool checkboxInitiallyCheckedValue = true; - int textboxIntValue = -42; - int? textboxNullableIntValue = null; - long textboxLongValue = 3_000_000_000; - long? textboxNullableLongValue = null; - float textboxFloatValue = 3.141f; - float? textboxNullableFloatValue = null; - double textboxDoubleValue = 3.14159265359d; - double? textboxNullableDoubleValue = null; - decimal textboxDecimalValue = 0.0000000000000000000000000001M; - decimal? textboxNullableDecimalValue = null; - decimal textboxDecimalInvalidValue = 0.0000000000000000000000000001M; - decimal? textboxNullableDecimalInvalidValue = null; +int textboxIntValue = -42; +int? textboxNullableIntValue = null; +long textboxLongValue = 3_000_000_000; +long? textboxNullableLongValue = null; +float textboxFloatValue = 3.141f; +float? textboxNullableFloatValue = null; +double textboxDoubleValue = 3.14159265359d; +double? textboxNullableDoubleValue = null; +decimal textboxDecimalValue = 0.0000000000000000000000000001M; +decimal? textboxNullableDecimalValue = null; +decimal textboxDecimalInvalidValue = 0.0000000000000000000000000001M; +decimal? textboxNullableDecimalInvalidValue = null; - bool includeFourthOption = false; - enum SelectableValue { First, Second, Third, Fourth } - SelectableValue selectValue = SelectableValue.Second; +int textboxGenericIntValue = -42; +Guid textboxGenericGuidValue = Guid.Empty; - void AddAndSelectNewSelectOption() - { - includeFourthOption = true; - selectValue = SelectableValue.Fourth; - } +bool includeFourthOption = false; +enum SelectableValue { First, Second, Third, Fourth } +SelectableValue selectValue = SelectableValue.Second; + +void AddAndSelectNewSelectOption() +{ + includeFourthOption = true; + selectValue = SelectableValue.Fourth; +} } diff --git a/src/Components/test/testassets/BasicTestApp/BindGenericComponent.razor b/src/Components/test/testassets/BasicTestApp/BindGenericComponent.razor new file mode 100644 index 0000000000..54072b918e --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/BindGenericComponent.razor @@ -0,0 +1,9 @@ +@typeparam TValue + + + +@code { + [Parameter] string Id { get; set; } + [Parameter] TValue Value { get; set; } + [Parameter] EventCallback ValueChanged { get; set; } +}