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 7376cf6b87..fa82390216 100644 --- a/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs +++ b/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs @@ -46,6 +46,9 @@ namespace Microsoft.AspNetCore.Components public static partial class BindMethods { public static string GetValue(System.DateTime value, string format) { throw null; } + public static string GetValue(System.DateTimeOffset value, string format) { throw null; } + public static string GetValue(System.DateTimeOffset? value, string format) { throw null; } + public static string GetValue(System.DateTime? value, string format) { throw null; } public static T GetValue(T value) { throw null; } } [System.AttributeUsageAttribute(System.AttributeTargets.Property, AllowMultiple=false, Inherited=true)] @@ -124,6 +127,8 @@ namespace Microsoft.AspNetCore.Components public static partial class EventCallbackFactoryBinderExtensions { public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, bool existingValue, System.Globalization.CultureInfo culture = null) { throw null; } + public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, System.DateTimeOffset existingValue, System.Globalization.CultureInfo culture = null) { throw null; } + public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, System.DateTimeOffset existingValue, string format, System.Globalization.CultureInfo culture = null) { throw null; } public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, System.DateTime existingValue, System.Globalization.CultureInfo culture = null) { throw null; } public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, System.DateTime existingValue, string format, System.Globalization.CultureInfo culture = null) { throw null; } public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, decimal existingValue, System.Globalization.CultureInfo culture = null) { throw null; } @@ -131,7 +136,10 @@ namespace Microsoft.AspNetCore.Components public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, int existingValue, System.Globalization.CultureInfo culture = null) { throw null; } public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, long existingValue, System.Globalization.CultureInfo culture = null) { throw null; } public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, bool? existingValue, System.Globalization.CultureInfo culture = null) { throw null; } + public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, System.DateTimeOffset? existingValue, System.Globalization.CultureInfo culture = null) { throw null; } + public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, System.DateTimeOffset? existingValue, string format, System.Globalization.CultureInfo culture = null) { throw null; } public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, System.DateTime? existingValue, System.Globalization.CultureInfo culture = null) { throw null; } + public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, System.DateTime? existingValue, string format, System.Globalization.CultureInfo culture = null) { throw null; } public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, decimal? existingValue, System.Globalization.CultureInfo culture = null) { throw null; } public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, double? existingValue, System.Globalization.CultureInfo culture = null) { throw null; } public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, int? existingValue, System.Globalization.CultureInfo culture = null) { throw null; } diff --git a/src/Components/Components/src/BindMethods.cs b/src/Components/Components/src/BindMethods.cs index 13ca0e3fa0..702b0c74b2 100644 --- a/src/Components/Components/src/BindMethods.cs +++ b/src/Components/Components/src/BindMethods.cs @@ -23,6 +23,25 @@ namespace Microsoft.AspNetCore.Components value == default ? null : (format == null ? value.ToString() : value.ToString(format)); + /// + /// Not intended to be used directly. + /// + public static string GetValue(DateTime? value, string format) => + value == default ? null + : (format == null ? value.ToString() : value.Value.ToString(format)); + /// + /// Not intended to be used directly. + /// + public static string GetValue(DateTimeOffset value, string format) => + value == default ? null + : (format == null ? value.ToString() : value.ToString(format)); + + /// + /// Not intended to be used directly. + /// + public static string GetValue(DateTimeOffset? value, string format) => + value == default ? null + : (format == null ? value.ToString() : value.Value.ToString(format)); } } diff --git a/src/Components/Components/src/EventCallbackFactoryBinderExtensions.cs b/src/Components/Components/src/EventCallbackFactoryBinderExtensions.cs index c8c8d9a0ec..0f8a6d547e 100644 --- a/src/Components/Components/src/EventCallbackFactoryBinderExtensions.cs +++ b/src/Components/Components/src/EventCallbackFactoryBinderExtensions.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Concurrent; using System.ComponentModel; -using System.Diagnostics; using System.Globalization; using System.Reflection; @@ -26,6 +25,7 @@ namespace Microsoft.AspNetCore.Components public static class EventCallbackFactoryBinderExtensions { private delegate bool BindConverter(object obj, CultureInfo culture, out T value); + private delegate bool BindConverterWithFormat(object obj, CultureInfo culture, string format, out T value); // Perf: conversion delegates are written as static funcs so we can prevent // allocations for these simple cases. @@ -261,9 +261,16 @@ namespace Microsoft.AspNetCore.Components } private static BindConverter ConvertToDateTime = ConvertToDateTimeCore; + private static BindConverterWithFormat ConvertToDateTimeWithFormat = ConvertToDateTimeCore; private static BindConverter ConvertToNullableDateTime = ConvertToNullableDateTimeCore; + private static BindConverterWithFormat ConvertToNullableDateTimeWithFormat = ConvertToNullableDateTimeCore; private static bool ConvertToDateTimeCore(object obj, CultureInfo culture, out DateTime value) + { + return ConvertToDateTimeCore(obj, culture, format: null, out value); + } + + private static bool ConvertToDateTimeCore(object obj, CultureInfo culture, string format, out DateTime value) { var text = (string)obj; if (string.IsNullOrEmpty(text)) @@ -272,17 +279,27 @@ namespace Microsoft.AspNetCore.Components return false; } - if (!DateTime.TryParse(text, culture ?? CultureInfo.CurrentCulture, DateTimeStyles.None, out var converted)) + if (format != null && DateTime.TryParseExact(text, format, culture ?? CultureInfo.CurrentCulture, DateTimeStyles.None, out var converted)) { - value = default; - return false; + value = converted; + return true; + } + else if (format == null && DateTime.TryParse(text, culture ?? CultureInfo.CurrentCulture, DateTimeStyles.None, out converted)) + { + value = converted; + return true; } - value = converted; - return true; + value = default; + return false; } private static bool ConvertToNullableDateTimeCore(object obj, CultureInfo culture, out DateTime? value) + { + return ConvertToNullableDateTimeCore(obj, culture, format: null, out value); + } + + private static bool ConvertToNullableDateTimeCore(object obj, CultureInfo culture, string format, out DateTime? value) { var text = (string)obj; if (string.IsNullOrEmpty(text)) @@ -291,14 +308,82 @@ namespace Microsoft.AspNetCore.Components return true; } - if (!DateTime.TryParse(text, culture ?? CultureInfo.CurrentCulture, DateTimeStyles.None, out var converted)) + if (format != null && DateTime.TryParseExact(text, format, culture ?? CultureInfo.CurrentCulture, DateTimeStyles.None, out var converted)) + { + value = converted; + return true; + } + else if (format == null && DateTime.TryParse(text, culture ?? CultureInfo.CurrentCulture, DateTimeStyles.None, out converted)) + { + value = converted; + return true; + } + + value = default; + return false; + } + + private static BindConverter ConvertToDateTimeOffset = ConvertToDateTimeOffsetCore; + private static BindConverterWithFormat ConvertToDateTimeOffsetWithFormat = ConvertToDateTimeOffsetCore; + private static BindConverter ConvertToNullableDateTimeOffset = ConvertToNullableDateTimeOffsetCore; + private static BindConverterWithFormat ConvertToNullableDateTimeOffsetWithFormat = ConvertToNullableDateTimeOffsetCore; + + private static bool ConvertToDateTimeOffsetCore(object obj, CultureInfo culture, out DateTimeOffset value) + { + return ConvertToDateTimeOffsetCore(obj, culture, format: null, out value); + } + + private static bool ConvertToDateTimeOffsetCore(object obj, CultureInfo culture, string format, out DateTimeOffset value) + { + var text = (string)obj; + if (string.IsNullOrEmpty(text)) { value = default; return false; } - value = converted; - return true; + if (format != null && DateTimeOffset.TryParseExact(text, format, culture ?? CultureInfo.CurrentCulture, DateTimeStyles.None, out var converted)) + { + value = converted; + return true; + } + else if (format == null && DateTimeOffset.TryParse(text, culture ?? CultureInfo.CurrentCulture, DateTimeStyles.None, out converted)) + { + value = converted; + return true; + } + + value = default; + return false; + } + + private static bool ConvertToNullableDateTimeOffsetCore(object obj, CultureInfo culture, out DateTimeOffset? value) + { + return ConvertToNullableDateTimeOffsetCore(obj, culture, format: null, out value); + } + + private static bool ConvertToNullableDateTimeOffsetCore(object obj, CultureInfo culture, string format, out DateTimeOffset? value) + { + var text = (string)obj; + if (string.IsNullOrEmpty(text)) + { + value = default; + return true; + } + + if (format != null && DateTimeOffset.TryParseExact(text, format, culture ?? CultureInfo.CurrentCulture, DateTimeStyles.None, out var converted)) + { + value = converted; + return true; + } + else if (format == null && DateTimeOffset.TryParse(text, culture ?? CultureInfo.CurrentCulture, DateTimeStyles.None, out converted)) + { + value = converted; + return true; + } + + value = default; + return false; } private static bool ConvertToEnum(object obj, CultureInfo culture, out T value) where T : struct, Enum @@ -602,26 +687,7 @@ namespace Microsoft.AspNetCore.Components DateTime existingValue, CultureInfo culture = null) { - return CreateBinderCore(factory, receiver, setter, culture, ConvertToDateTime); - } - - /// - /// For internal use only. - /// - /// - /// - /// - /// - /// - /// - public static EventCallback CreateBinder( - this EventCallbackFactory factory, - object receiver, - Action setter, - DateTime? existingValue, - CultureInfo culture = null) - { - return CreateBinderCore(factory, receiver, setter, culture, ConvertToNullableDateTime); + return CreateBinderCore(factory, receiver, setter, culture, format: null, ConvertToDateTimeWithFormat); } /// @@ -642,45 +708,127 @@ namespace Microsoft.AspNetCore.Components string format, CultureInfo culture = null) { - // 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, culture, format); - converted = true; - } - catch - { - } + return CreateBinderCore(factory, receiver, setter, culture, format, ConvertToDateTimeWithFormat); + } - // 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, + CultureInfo culture = null) + { + return CreateBinderCore(factory, receiver, setter, culture, format: null, ConvertToNullableDateTimeWithFormat); + } - static DateTime ConvertDateTime(object obj, CultureInfo culture, string format) - { - var text = (string)obj; - if (string.IsNullOrEmpty(text)) - { - return default; - } - else if (format != null && DateTime.TryParseExact(text, format, culture ?? CultureInfo.CurrentCulture, DateTimeStyles.RoundtripKind, out var value)) - { - return value; - } - else - { - return DateTime.Parse(text, culture ?? CultureInfo.CurrentCulture, DateTimeStyles.RoundtripKind); - } - } + /// + /// For internal use only. + /// + /// + /// + /// + /// + /// + /// + /// + public static EventCallback CreateBinder( + this EventCallbackFactory factory, + object receiver, + Action setter, + DateTime? existingValue, + string format, + CultureInfo culture = null) + { + return CreateBinderCore(factory, receiver, setter, culture, format, ConvertToNullableDateTimeWithFormat); + } + + /// + /// For internal use only. + /// + /// + /// + /// + /// + /// + /// + public static EventCallback CreateBinder( + this EventCallbackFactory factory, + object receiver, + Action setter, + DateTimeOffset existingValue, + CultureInfo culture = null) + { + return CreateBinderCore(factory, receiver, setter, culture, format: null, ConvertToDateTimeOffsetWithFormat); + } + + /// + /// For internal use only. + /// + /// + /// + /// + /// + /// + /// + /// + public static EventCallback CreateBinder( + this EventCallbackFactory factory, + object receiver, + Action setter, + DateTimeOffset existingValue, + string format, + CultureInfo culture = null) + { + return CreateBinderCore(factory, receiver, setter, culture, format, ConvertToDateTimeOffsetWithFormat); + } + + /// + /// For internal use only. + /// + /// + /// + /// + /// + /// + /// + public static EventCallback CreateBinder( + this EventCallbackFactory factory, + object receiver, + Action setter, + DateTimeOffset? existingValue, + CultureInfo culture = null) + { + return CreateBinderCore(factory, receiver, setter, culture, format: null, ConvertToNullableDateTimeOffsetWithFormat); + } + + /// + /// For internal use only. + /// + /// + /// + /// + /// + /// + /// + /// + public static EventCallback CreateBinder( + this EventCallbackFactory factory, + object receiver, + Action setter, + DateTimeOffset? existingValue, + string format, + CultureInfo culture = null) + { + return CreateBinderCore(factory, receiver, setter, culture, format, ConvertToNullableDateTimeOffsetWithFormat); } /// @@ -746,6 +894,50 @@ namespace Microsoft.AspNetCore.Components return factory.Create(receiver, callback); } + private static EventCallback CreateBinderCore( + this EventCallbackFactory factory, + object receiver, + Action setter, + CultureInfo culture, + string format, + BindConverterWithFormat converter) + { + Action callback = e => + { + T value = default; + var converted = false; + try + { + converted = converter(e.Value, culture, format, out value); + } + catch + { + } + + // We only invoke the setter if the conversion didn't throw, or if the newly-entered value is empty. + // If the user entered some non-empty value we couldn't parse, we leave the state of the .NET field + // unchanged, which for a two-way binding results in the UI reverting to its previous valid state + // because the diff will see the current .NET output no longer matches the render tree since we + // patched it to reflect the state of the UI. + // + // This reversion behavior is valuable because alternatives are problematic: + // - If we assigned default(T) on failure, the user would lose whatever data they were editing, + // for example if they accidentally pressed an alphabetical key while editing a number with + // @bind:event="oninput" + // - If the diff mechanism didn't revert to the previous good value, the user wouldn't necessarily + // know that the data they are submitting is different from what they think they've typed + if (converted) + { + setter(value); + } + else if (string.Empty.Equals(e.Value)) + { + setter(default); + } + }; + 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 @@ -821,6 +1013,14 @@ namespace Microsoft.AspNetCore.Components { converter = ConvertToNullableDateTime; } + else if (typeof(T) == typeof(DateTimeOffset)) + { + converter = ConvertToDateTimeOffset; + } + else if (typeof(T) == typeof(DateTimeOffset?)) + { + converter = ConvertToNullableDateTimeOffset; + } else if (typeof(T).IsEnum) { // We have to deal invoke this dynamically to work around the type constraint on Enum.TryParse. diff --git a/src/Components/Components/test/EventCallbackFactoryBinderExtensionsTest.cs b/src/Components/Components/test/EventCallbackFactoryBinderExtensionsTest.cs index 6136b5b439..62402f2265 100644 --- a/src/Components/Components/test/EventCallbackFactoryBinderExtensionsTest.cs +++ b/src/Components/Components/test/EventCallbackFactoryBinderExtensionsTest.cs @@ -421,7 +421,6 @@ namespace Microsoft.AspNetCore.Components Assert.Equal(1, component.Count); } - // For now format is only supported by this specific method. [Fact] public async Task CreateBinder_DateTime_Format() { @@ -442,6 +441,104 @@ namespace Microsoft.AspNetCore.Components Assert.Equal(1, component.Count); } + [Fact] + public async Task CreateBinder_NullableDateTime_Format() + { + // Arrange + var value = (DateTime?)DateTime.Now; + var component = new EventCountingComponent(); + Action setter = (_) => value = _; + var format = "ddd yyyy-MM-dd"; + + var binder = EventCallback.Factory.CreateBinder(component, setter, value, format); + + var expectedValue = new DateTime(2018, 3, 4); + + // Act + await binder.InvokeAsync(new UIChangeEventArgs() { Value = expectedValue.ToString(format), }); + + Assert.Equal(expectedValue, value); + Assert.Equal(1, component.Count); + } + + [Fact] + public async Task CreateBinder_DateTimeOffset() + { + // Arrange + var value = DateTimeOffset.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); + } + + [Fact] + public async Task CreateBinder_NullableDateTimeOffset() + { + // Arrange + var value = (DateTimeOffset?)DateTimeOffset.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); + } + + [Fact] + public async Task CreateBinder_DateTimeOffset_Format() + { + // Arrange + var value = DateTimeOffset.Now; + var component = new EventCountingComponent(); + Action setter = (_) => value = _; + var format = "ddd yyyy-MM-dd"; + + var binder = EventCallback.Factory.CreateBinder(component, setter, value, format); + + var expectedValue = new DateTime(2018, 3, 4); + + // Act + await binder.InvokeAsync(new UIChangeEventArgs() { Value = expectedValue.ToString(format), }); + + Assert.Equal(expectedValue, value); + Assert.Equal(1, component.Count); + } + + [Fact] + public async Task CreateBinder_NullableDateTimeOffset_Format() + { + // Arrange + var value = (DateTimeOffset?)DateTimeOffset.Now; + var component = new EventCountingComponent(); + Action setter = (_) => value = _; + var format = "ddd yyyy-MM-dd"; + + var binder = EventCallback.Factory.CreateBinder(component, setter, value, format); + + var expectedValue = new DateTime(2018, 3, 4); + + // Act + await binder.InvokeAsync(new UIChangeEventArgs() { Value = expectedValue.ToString(format), }); + + Assert.Equal(expectedValue, value); + Assert.Equal(1, component.Count); + } + // This uses a type converter [Fact] public async Task CreateBinder_Guid() diff --git a/src/Components/test/E2ETest/ServerExecutionTests/GlobalizationTest.cs b/src/Components/test/E2ETest/ServerExecutionTests/GlobalizationTest.cs index 3e62e34857..bb3e633204 100644 --- a/src/Components/test/E2ETest/ServerExecutionTests/GlobalizationTest.cs +++ b/src/Components/test/E2ETest/ServerExecutionTests/GlobalizationTest.cs @@ -91,6 +91,88 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests Browser.Equal(new DateTimeOffset(new DateTime(2000, 1, 2)).ToString(cultureInfo), () => display.Text); } + // The logic is different for verifying culture-invariant fields. The problem is that the logic for what + // kinds of text a field accepts is determined by the browser and language - it's not general. So while + // type="number" and type="date" produce fixed-format and culture-invariant input/output via the "value" + // attribute - the actual input processing is harder to nail down. In practice this is only a problem + // with dates. + // + // For this reason we avoid sending keys directly to the field, and let two-way binding do its thing instead. + // + // A brief summary: + // 1. Input a value (invariant culture if using number field, or current culture to extra input if using date field) + // 2. trigger onchange + // 3. Verify "value" field (current culture) + // 4. Verify the input field's value attribute (invariant culture) + // + // We need to do step 4 to make sure that the value we're entering can "stick" in the form field. + // We can't use ".Text" because DOM reasons :( + [Theory] + [InlineData("en-US")] + [InlineData("fr-FR")] + public void CanSetCultureAndParseCultureInvariantNumbersAndDatesWithInputFields(string culture) + { + var cultureInfo = CultureInfo.GetCultureInfo(culture); + + var selector = new SelectElement(Browser.FindElement(By.Id("culture-selector"))); + selector.SelectByValue(culture); + + // That should have triggered a redirect, wait for the main test selector to come up. + MountTestComponent(); + WaitUntilExists(By.Id("globalization-cases")); + + var cultureDisplay = WaitUntilExists(By.Id("culture-name-display")); + Assert.Equal($"Culture is: {culture}", cultureDisplay.Text); + + // int + var input = Browser.FindElement(By.Id("input_type_number_int")); + var display = Browser.FindElement(By.Id("input_type_number_int_value")); + Browser.Equal(42.ToString(cultureInfo), () => display.Text); + Browser.Equal(42.ToString(CultureInfo.InvariantCulture), () => input.GetAttribute("value")); + + input.Clear(); + input.SendKeys(9000.ToString(CultureInfo.InvariantCulture)); + input.SendKeys("\t"); + Browser.Equal(9000.ToString(cultureInfo), () => display.Text); + Browser.Equal(9000.ToString(CultureInfo.InvariantCulture), () => input.GetAttribute("value")); + + // decimal + input = Browser.FindElement(By.Id("input_type_number_decimal")); + display = Browser.FindElement(By.Id("input_type_number_decimal_value")); + Browser.Equal(4.2m.ToString(cultureInfo), () => display.Text); + Browser.Equal(4.2m.ToString(CultureInfo.InvariantCulture), () => input.GetAttribute("value")); + + input.Clear(); + input.SendKeys(9000.42m.ToString(CultureInfo.InvariantCulture)); + input.SendKeys("\t"); + Browser.Equal(9000.42m.ToString(cultureInfo), () => display.Text); + Browser.Equal(9000.42m.ToString(CultureInfo.InvariantCulture), () => input.GetAttribute("value")); + + // datetime + input = Browser.FindElement(By.Id("input_type_date_datetime")); + display = Browser.FindElement(By.Id("input_type_date_datetime_value")); + var extraInput = Browser.FindElement(By.Id("input_type_date_datetime_extrainput")); + Browser.Equal(new DateTime(1985, 3, 4).ToString(cultureInfo), () => display.Text); + Browser.Equal(new DateTime(1985, 3, 4).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), () => input.GetAttribute("value")); + + ReplaceText(extraInput, new DateTime(2000, 1, 2).ToString(cultureInfo)); + extraInput.SendKeys("\t"); + Browser.Equal(new DateTime(2000, 1, 2).ToString(cultureInfo), () => display.Text); + Browser.Equal(new DateTime(2000, 1, 2).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), () => input.GetAttribute("value")); + + // datetimeoffset + input = Browser.FindElement(By.Id("input_type_date_datetimeoffset")); + display = Browser.FindElement(By.Id("input_type_date_datetimeoffset_value")); + extraInput = Browser.FindElement(By.Id("input_type_date_datetimeoffset_extrainput")); + Browser.Equal(new DateTimeOffset(new DateTime(1985, 3, 4)).ToString(cultureInfo), () => display.Text); + Browser.Equal(new DateTimeOffset(new DateTime(1985, 3, 4)).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), () => input.GetAttribute("value")); + + ReplaceText(extraInput, new DateTimeOffset(new DateTime(2000, 1, 2)).ToString(cultureInfo)); + extraInput.SendKeys("\t"); + Browser.Equal(new DateTimeOffset(new DateTime(2000, 1, 2)).ToString(cultureInfo), () => display.Text); + Browser.Equal(new DateTimeOffset(new DateTime(2000, 1, 2)).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), () => input.GetAttribute("value")); + } + // see: https://github.com/seleniumhq/selenium-google-code-issue-archive/issues/214 // // Calling Clear() can trigger onchange, which will revert the value to its default. diff --git a/src/Components/test/E2ETest/Tests/BindTest.cs b/src/Components/test/E2ETest/Tests/BindTest.cs index 3d17faf792..172c6973bc 100644 --- a/src/Components/test/E2ETest/Tests/BindTest.cs +++ b/src/Components/test/E2ETest/Tests/BindTest.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.Globalization; using BasicTestApp; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; @@ -640,5 +641,241 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests Browser.Equal(newValue, () => boundValue.Text); Assert.Equal(newValue, mirrorValue.GetAttribute("value")); } + + // For date comparisons, we parse (non-formatted) values to compare them. Client-side and server-side + // Blazor have different formatting behaviour by default. + [Fact] + public void CanBindTextboxDateTime() + { + var target = Browser.FindElement(By.Id("textbox-datetime")); + var boundValue = Browser.FindElement(By.Id("textbox-datetime-value")); + var mirrorValue = Browser.FindElement(By.Id("textbox-datetime-mirror")); + var expected = new DateTime(1985, 3, 4); + Assert.Equal(expected, DateTime.Parse(target.GetAttribute("value"))); + Assert.Equal(expected, DateTime.Parse(boundValue.Text)); + Assert.Equal(expected, DateTime.Parse(mirrorValue.GetAttribute("value"))); + + // Clear textbox; value updates to 01/01/0001 because that's the default + target.Clear(); + expected = default; + Browser.Equal(expected, () => DateTime.Parse(target.GetAttribute("value"))); + Assert.Equal(expected, DateTime.Parse(boundValue.Text)); + Assert.Equal(expected, DateTime.Parse(mirrorValue.GetAttribute("value"))); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated + target.SendKeys(Keys.Control + "a"); // select all + target.SendKeys("01/02/2000 00:00:00\t"); + expected = new DateTime(2000, 1, 2); + Browser.Equal(expected, () => DateTime.Parse(boundValue.Text)); + Assert.Equal(expected, DateTime.Parse(mirrorValue.GetAttribute("value"))); + } + + // For date comparisons, we parse (non-formatted) values to compare them. Client-side and server-side + // Blazor have different formatting behaviour by default. + [Fact] + public void CanBindTextboxNullableDateTime() + { + var target = Browser.FindElement(By.Id("textbox-nullable-datetime")); + var boundValue = Browser.FindElement(By.Id("textbox-nullable-datetime-value")); + var mirrorValue = Browser.FindElement(By.Id("textbox-nullable-datetime-mirror")); + Assert.Equal(string.Empty, target.GetAttribute("value")); + Assert.Equal(string.Empty, boundValue.Text); + Assert.Equal(string.Empty, mirrorValue.GetAttribute("value")); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated + target.Clear(); + Browser.Equal("", () => boundValue.Text); + Assert.Equal("", mirrorValue.GetAttribute("value")); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated + var expected = new DateTime(2000, 1, 2); + target.SendKeys("01/02/2000 00:00:00\t"); + Browser.Equal(expected, () => DateTime.Parse(boundValue.Text)); + Assert.Equal(expected, DateTime.Parse(mirrorValue.GetAttribute("value"))); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated + target.Clear(); + target.SendKeys("\t"); + Browser.Equal(string.Empty, () => boundValue.Text); + Assert.Equal(string.Empty, mirrorValue.GetAttribute("value")); + } + + // For date comparisons, we parse (non-formatted) values to compare them. Client-side and server-side + // Blazor have different formatting behaviour by default. + [Fact] + public void CanBindTextboxDateTimeOffset() + { + var target = Browser.FindElement(By.Id("textbox-datetimeoffset")); + var boundValue = Browser.FindElement(By.Id("textbox-datetimeoffset-value")); + var mirrorValue = Browser.FindElement(By.Id("textbox-datetimeoffset-mirror")); + var expected = new DateTimeOffset(new DateTime(1985, 3, 4), TimeSpan.FromHours(8)); + Assert.Equal(expected, DateTimeOffset.Parse(target.GetAttribute("value"))); + Assert.Equal(expected, DateTimeOffset.Parse(boundValue.Text)); + Assert.Equal(expected, DateTimeOffset.Parse(mirrorValue.GetAttribute("value"))); + + // Clear textbox; value updates to 01/01/0001 because that's the default + target.Clear(); + expected = default; + Browser.Equal(expected, () => DateTimeOffset.Parse(target.GetAttribute("value"))); + Assert.Equal(expected, DateTimeOffset.Parse(boundValue.Text)); + Assert.Equal(expected, DateTimeOffset.Parse(mirrorValue.GetAttribute("value"))); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated + target.SendKeys(Keys.Control + "a"); // select all + target.SendKeys("01/02/2000 00:00:00 +08:00\t"); + expected = new DateTimeOffset(new DateTime(2000, 1, 2), TimeSpan.FromHours(8)); + Browser.Equal(expected, () => DateTimeOffset.Parse(boundValue.Text)); + Assert.Equal(expected, DateTimeOffset.Parse(mirrorValue.GetAttribute("value"))); + } + + // For date comparisons, we parse (non-formatted) values to compare them. Client-side and server-side + // Blazor have different formatting behaviour by default. + [Fact] + public void CanBindTextboxNullableDateTimeOffset() + { + var target = Browser.FindElement(By.Id("textbox-nullable-datetimeoffset")); + var boundValue = Browser.FindElement(By.Id("textbox-nullable-datetimeoffset-value")); + var mirrorValue = Browser.FindElement(By.Id("textbox-nullable-datetimeoffset-mirror")); + Assert.Equal(string.Empty, target.GetAttribute("value")); + Assert.Equal(string.Empty, boundValue.Text); + Assert.Equal(string.Empty, mirrorValue.GetAttribute("value")); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated + target.Clear(); + Browser.Equal("", () => boundValue.Text); + Assert.Equal("", mirrorValue.GetAttribute("value")); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated + target.SendKeys("01/02/2000 00:00:00 +08:00" + "\t"); + var expected = new DateTimeOffset(new DateTime(2000, 1, 2), TimeSpan.FromHours(8)); + Browser.Equal(expected, () => DateTimeOffset.Parse(boundValue.Text)); + Assert.Equal(expected, DateTimeOffset.Parse(mirrorValue.GetAttribute("value"))); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated + target.Clear(); + target.SendKeys("\t"); + Browser.Equal(string.Empty, () => boundValue.Text); + Assert.Equal(string.Empty, mirrorValue.GetAttribute("value")); + } + + // For date comparisons, we parse (non-formatted) values to compare them. Client-side and server-side + // Blazor have different formatting behaviour by default. + [Fact] + public void CanBindTextboxDateTimeWithFormat() + { + var target = Browser.FindElement(By.Id("textbox-datetime-format")); + var boundValue = Browser.FindElement(By.Id("textbox-datetime-format-value")); + var mirrorValue = Browser.FindElement(By.Id("textbox-datetime-format-mirror")); + var expected = new DateTime(1985, 3, 4); + Assert.Equal("03-04", target.GetAttribute("value")); + Assert.Equal(expected, DateTime.Parse(boundValue.Text)); + Assert.Equal(expected, DateTime.Parse(mirrorValue.GetAttribute("value"))); + + // Clear textbox; value updates to emtpy because that's what we do for `default` when there's a format + target.Clear(); + target.SendKeys("\t"); + expected = default; + Browser.Equal(string.Empty, () => target.GetAttribute("value")); + Assert.Equal(expected, DateTime.Parse(boundValue.Text)); + Assert.Equal(expected, DateTime.Parse(mirrorValue.GetAttribute("value"))); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated + target.SendKeys(Keys.Control + "a"); // select all + target.SendKeys("01-02\t"); + expected = new DateTime(DateTime.Now.Year, 1, 2); + Browser.Equal(expected, () => DateTime.Parse(boundValue.Text)); + Assert.Equal(expected, DateTime.Parse(mirrorValue.GetAttribute("value"))); + } + + // For date comparisons, we parse (non-formatted) values to compare them. Client-side and server-side + // Blazor have different formatting behaviour by default. + [Fact] + public void CanBindTextboxNullableDateTimeWithFormat() + { + var target = Browser.FindElement(By.Id("textbox-nullable-datetime-format")); + var boundValue = Browser.FindElement(By.Id("textbox-nullable-datetime-format-value")); + var mirrorValue = Browser.FindElement(By.Id("textbox-nullable-datetime-format-mirror")); + Assert.Equal(string.Empty, target.GetAttribute("value")); + Assert.Equal(string.Empty, boundValue.Text); + Assert.Equal(string.Empty, mirrorValue.GetAttribute("value")); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated + target.Clear(); + Browser.Equal("", () => boundValue.Text); + Assert.Equal("", mirrorValue.GetAttribute("value")); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated + target.SendKeys("01-02\t"); + var expected = new DateTime(DateTime.Now.Year, 1, 2); + Browser.Equal(expected, () => DateTime.Parse(boundValue.Text)); + Assert.Equal(expected, DateTime.Parse(mirrorValue.GetAttribute("value"))); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated + target.Clear(); + target.SendKeys("\t"); + Browser.Equal(string.Empty, () => boundValue.Text); + Assert.Equal(string.Empty, mirrorValue.GetAttribute("value")); + } + + // For date comparisons, we parse (non-formatted) values to compare them. Client-side and server-side + // Blazor have different formatting behaviour by default. + [Fact] + public void CanBindTextboxDateTimeOffsetWithFormat() + { + var target = Browser.FindElement(By.Id("textbox-datetimeoffset-format")); + var boundValue = Browser.FindElement(By.Id("textbox-datetimeoffset-format-value")); + var mirrorValue = Browser.FindElement(By.Id("textbox-datetimeoffset-format-mirror")); + var expected = new DateTimeOffset(new DateTime(1985, 3, 4), TimeSpan.FromHours(8)); + Assert.Equal("03-04", target.GetAttribute("value")); + Assert.Equal(expected, DateTimeOffset.Parse(boundValue.Text)); + Assert.Equal(expected, DateTimeOffset.Parse(mirrorValue.GetAttribute("value"))); + + // Clear textbox; value updates to emtpy because that's what we do for `default` when there's a format + target.Clear(); + expected = default; + Browser.Equal(string.Empty, () => target.GetAttribute("value")); + Assert.Equal(expected, DateTimeOffset.Parse(boundValue.Text)); + Assert.Equal(expected, DateTimeOffset.Parse(mirrorValue.GetAttribute("value"))); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated + target.SendKeys(Keys.Control + "a"); // select all + target.SendKeys("01-02\t"); + expected = new DateTimeOffset(new DateTime(DateTime.Now.Year, 1, 2), TimeSpan.FromHours(0)); + Browser.Equal(expected.DateTime, () => DateTimeOffset.Parse(boundValue.Text).DateTime); + Assert.Equal(expected.DateTime, DateTimeOffset.Parse(mirrorValue.GetAttribute("value")).DateTime); + } + + // For date comparisons, we parse (non-formatted) values to compare them. Client-side and server-side + // Blazor have different formatting behaviour by default. + // + // Guess what! Client-side and server-side also understand timezones differently. So for now we're comparing + // the parsed output without consideration for the timezone + [Fact] + public void CanBindTextboxNullableDateTimeOffsetWithFormat() + { + var target = Browser.FindElement(By.Id("textbox-nullable-datetimeoffset")); + var boundValue = Browser.FindElement(By.Id("textbox-nullable-datetimeoffset-value")); + var mirrorValue = Browser.FindElement(By.Id("textbox-nullable-datetimeoffset-mirror")); + Assert.Equal(string.Empty, target.GetAttribute("value")); + Assert.Equal(string.Empty, boundValue.Text); + Assert.Equal(string.Empty, mirrorValue.GetAttribute("value")); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated + target.Clear(); + Browser.Equal("", () => boundValue.Text); + Assert.Equal("", mirrorValue.GetAttribute("value")); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated + target.SendKeys("01-02" + "\t"); + var expected = new DateTimeOffset(new DateTime(DateTime.Now.Year, 1, 2), TimeSpan.FromHours(0)); + Browser.Equal(expected.DateTime, () => DateTimeOffset.Parse(boundValue.Text).DateTime); + Assert.Equal(expected.DateTime, DateTimeOffset.Parse(mirrorValue.GetAttribute("value")).DateTime); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated + target.Clear(); + target.SendKeys("\t"); + Browser.Equal(string.Empty, () => boundValue.Text); + Assert.Equal(string.Empty, mirrorValue.GetAttribute("value")); + } } } diff --git a/src/Components/test/testassets/BasicTestApp/BindCasesComponent.razor b/src/Components/test/testassets/BasicTestApp/BindCasesComponent.razor index 113bfd3846..7b827d633f 100644 --- a/src/Components/test/testassets/BasicTestApp/BindCasesComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/BindCasesComponent.razor @@ -110,6 +110,56 @@

+

Date Textboxes (type=text)

+

+ DateTime: + + @textboxDateTimeValue + +

+

+ Nullable DateTime: + + @textboxNullableDateTimeValue + +

+

+ DateTimeOffset: + + @textboxDateTimeOffsetValue + +

+

+ Nullable DateTimeOffset: + + @textboxNullableDateTimeOffsetValue + +

+

+ DateTime (format): + + @textboxDateTimeFormatValue + +

+

+ Nullable DateTime (format): + + @textboxNullableDateTimeFormatValue + +

+

+ DateTimeOffset (format): + + @textboxDateTimeOffsetFormatValue + +

+

+ Nullable DateTimeOffset (format): + + @textboxNullableDateTimeOffsetFormatValue + +

+

Text Area

Initially blank: @@ -145,7 +195,8 @@

Select

+ @inputTypeNumberInt + +

+ decimal: + @inputTypeNumberDecimal +
+ + +
+

Dates using bind in date fields

+
+ DateTime: + + @inputTypeDateDateTime +
+
+ DateTimeOffset: + + @inputTypeDateDateTimeOffset +
+
@code { int inputTypeTextInt = 42; @@ -32,4 +59,10 @@ DateTime inputTypeTextDateTime = new DateTime(1985, 3, 4); DateTimeOffset inputTypeTextDateTimeOffset = new DateTimeOffset(new DateTime(1985, 3, 4)); + + int inputTypeNumberInt = 42; + decimal inputTypeNumberDecimal = 4.2m; + + DateTime inputTypeDateDateTime = new DateTime(1985, 3, 4); + DateTimeOffset inputTypeDateDateTimeOffset = new DateTimeOffset(new DateTime(1985, 3, 4)); }