From 61e07eb61508452ce7e3e43ee0713847002928b9 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Sun, 4 Mar 2018 20:08:00 +0000 Subject: [PATCH] Support @bind for enum and DateTime values (with optional format string) --- .../BlazorIntermediateNodeWriter.cs | 34 ++++-- .../RenderTreeBuilder.cs | 4 + .../Components/BindMethods.cs | 75 ++++++++++++ .../Components/BlazorComponent.cs | 7 ++ .../RazorCompilerTest.cs | 107 ++++++++++++++++-- 5 files changed, 208 insertions(+), 19 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Blazor/Components/BindMethods.cs diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorIntermediateNodeWriter.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorIntermediateNodeWriter.cs index 65ed3cd485..5f921bcfbe 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorIntermediateNodeWriter.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorIntermediateNodeWriter.cs @@ -12,6 +12,7 @@ using AngleSharp.Parser.Html; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.CodeGeneration; using Microsoft.AspNetCore.Razor.Language.Intermediate; +using Microsoft.CodeAnalysis.CSharp; namespace Microsoft.AspNetCore.Blazor.Razor { @@ -27,6 +28,8 @@ namespace Microsoft.AspNetCore.Blazor.Razor new[] { "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr" }, StringComparer.OrdinalIgnoreCase); private readonly static Regex bindExpressionRegex = new Regex(@"^bind\((.+)\)$"); + private readonly static CSharpParseOptions bindArgsParseOptions + = CSharpParseOptions.Default.WithKind(CodeAnalysis.SourceCodeKind.Script); private readonly ScopeStack _scopeStack = new ScopeStack(); private string _unconsumedHtml; @@ -323,24 +326,31 @@ namespace Microsoft.AspNetCore.Blazor.Razor var bindMatch = bindExpressionRegex.Match(token.AttributeValue.Content); if (bindMatch.Success) { - // The @bind(X) syntax is special. We convert it to a pair of attributes: - // [1] value=@X - var valueExpression = bindMatch.Groups[1].Value; - WriteAttribute(context.CodeWriter, "value", - new IntermediateToken { Kind = TokenKind.CSharp, Content = valueExpression }); + // TODO: Consider alternatives to the @bind syntax. The following is very strange. - // [2] @onchange(newValue => { X = newValue; }) - var isCheckbox = tag.Data.Equals("input", StringComparison.OrdinalIgnoreCase) - && tag.Attributes.Any(a => - a.Key.Equals("type", StringComparison.Ordinal) - && a.Value.Equals("checkbox", StringComparison.Ordinal)); - var castToType = isCheckbox ? "bool" : "string"; + // The @bind(X, Y, Z, ...) syntax is special. We convert it to a pair of attributes: + // [1] value=@BindMethods.GetValue(X, Y, Z, ...) + var valueParams = bindMatch.Groups[1].Value; + WriteAttribute(context.CodeWriter, "value", new IntermediateToken + { + Kind = TokenKind.CSharp, + Content = $"{RenderTreeBuilder.BindMethodsGetValue}({valueParams})" + }); + + // [2] @onchange(BindSetValue(parsed => { X = parsed; }, X, Y, Z, ...)) + var parsedArgs = CSharpSyntaxTree.ParseText(valueParams, bindArgsParseOptions); + var parsedArgsSplit = parsedArgs.GetRoot().ChildNodes().Select(x => x.ToString()).ToList(); + if (parsedArgsSplit.Count > 0) + { + parsedArgsSplit.Insert(0, $"_parsedValue_ => {{ {parsedArgsSplit[0]} = _parsedValue_; }}"); + } + var parsedArgsJoined = string.Join(", ", parsedArgsSplit); var onChangeAttributeToken = new PendingAttributeToken { AttributeValue = new IntermediateToken { Kind = TokenKind.CSharp, - Content = $"onchange(_newValue_ => {{ {valueExpression} = ({castToType})_newValue_; }})" + Content = $"onchange({RenderTreeBuilder.BindMethodsSetValue}({parsedArgsJoined}))" } }; WriteElementAttributeToken(context, tag, onChangeAttributeToken); diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/RenderTreeBuilder.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/RenderTreeBuilder.cs index 00742c7f34..398c5ad064 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/RenderTreeBuilder.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/RenderTreeBuilder.cs @@ -24,5 +24,9 @@ namespace Microsoft.AspNetCore.Blazor.Razor public static readonly string GetFrames = nameof(GetFrames); public static readonly string ChildContent = nameof(ChildContent); + + public static readonly string BindMethodsGetValue = "Microsoft.AspNetCore.Blazor.Components.BindMethods.GetValue"; + + public static readonly string BindMethodsSetValue = "Microsoft.AspNetCore.Blazor.Components.BindMethods.SetValue"; } } diff --git a/src/Microsoft.AspNetCore.Blazor/Components/BindMethods.cs b/src/Microsoft.AspNetCore.Blazor/Components/BindMethods.cs new file mode 100644 index 0000000000..e90d6e84fc --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor/Components/BindMethods.cs @@ -0,0 +1,75 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Globalization; + +namespace Microsoft.AspNetCore.Blazor.Components +{ + /// + /// Methods used internally by @bind syntax. Not intended to be used directly. + /// + public static class BindMethods + { + /// + /// Not intended to be used directly. + /// + public static T GetValue(T value) => value; + + /// + /// Not intended to be used directly. + /// + public static string GetValue(DateTime value, string format) => + value == default ? null + : (format == null ? value.ToString() : value.ToString(format)); + + /// + /// Not intended to be used directly. + /// + public static Action SetValue(Action setter, string existingValue) + => objValue => setter((string)objValue); + + /// + /// Not intended to be used directly. + /// + public static Action SetValue(Action setter, bool existingValue) + => objValue => setter((bool)objValue); + + /// + /// Not intended to be used directly. + /// + public static Action SetValue(Action setter, T existingValue) => objValue => + { + if (typeof(T).IsEnum) + { + var parsedValue = Enum.Parse(typeof(T), (string)objValue); + setter((T)parsedValue); + } + else + { + throw new ArgumentException($"@bind syntax does not accept values of type {typeof(T).FullName}. To read and write this value type, wrap it in a property of type string with suitable getters and setters."); + } + }; + + /// + /// Not intended to be used directly. + /// + public static Action SetValue(Action setter, DateTime existingValue) + => objValue => SetDateTimeValue(setter, objValue, null); + + /// + /// Not intended to be used directly. + /// + public static Action SetValue(Action setter, DateTime existingValue, string format) + => objValue => SetDateTimeValue(setter, objValue, format); + + private static void SetDateTimeValue(Action setter, object objValue, string format) + { + var stringValue = (string)objValue; + var parsedValue = string.IsNullOrEmpty(stringValue) ? default + : format != null && DateTime.TryParseExact(stringValue, format, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsedExact) ? parsedExact + : DateTime.Parse(stringValue); + setter(parsedValue); + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor/Components/BlazorComponent.cs b/src/Microsoft.AspNetCore.Blazor/Components/BlazorComponent.cs index 32d11ffa68..c4b7989e14 100644 --- a/src/Microsoft.AspNetCore.Blazor/Components/BlazorComponent.cs +++ b/src/Microsoft.AspNetCore.Blazor/Components/BlazorComponent.cs @@ -185,6 +185,13 @@ namespace Microsoft.AspNetCore.Blazor.Components protected RenderTreeFrame bind(object value) => throw new NotImplementedException($"{nameof(bind)} is a compile-time symbol only and should not be invoked."); + /// + /// Applies two-way data binding between the element and the property. + /// + /// The model property to be bound to the element. + protected RenderTreeFrame bind(DateTime value, string format) + => throw new NotImplementedException($"{nameof(bind)} is a compile-time symbol only and should not be invoked."); + /// /// Handles click events by invoking . /// diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs index 122b92ebc8..2464f92610 100644 --- a/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs @@ -393,11 +393,72 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test } [Fact] - public void SupportsTwoWayBindingForCheckboxes() + public void SupportsTwoWayBindingForDateValues() { // Arrange/Act var component = CompileToComponent( - @" + @" + @functions { + public DateTime MyDate { get; set; } = new DateTime(2018, 3, 4, 1, 2, 3); + }"); + var myDateProperty = component.GetType().GetProperty("MyDate"); + + // Assert + var frames = GetRenderTree(component); + Assert.Collection(frames, + frame => AssertFrame.Element(frame, "input", 3, 0), + frame => AssertFrame.Attribute(frame, "value", new DateTime(2018, 3, 4, 1, 2, 3).ToString(), 1), + frame => + { + AssertFrame.Attribute(frame, "onchange", 2); + + // Trigger the change event to show it updates the property + var newDateValue = new DateTime(2018, 3, 5, 4, 5, 6); + ((UIEventHandler)frame.AttributeValue)(new UIChangeEventArgs + { + Value = newDateValue.ToString() + }); + Assert.Equal(newDateValue, myDateProperty.GetValue(component)); + }, + frame => AssertFrame.Text(frame, "\n", 3)); + } + + [Fact] + public void SupportsTwoWayBindingForDateValuesWithFormatString() + { + // Arrange/Act + var component = CompileToComponent( + @" + @functions { + public DateTime MyDate { get; set; } = new DateTime(2018, 3, 4); + }"); + var myDateProperty = component.GetType().GetProperty("MyDate"); + + // Assert + var frames = GetRenderTree(component); + Assert.Collection(frames, + frame => AssertFrame.Element(frame, "input", 3, 0), + frame => AssertFrame.Attribute(frame, "value", "Sun 2018-03-04", 1), + frame => + { + AssertFrame.Attribute(frame, "onchange", 2); + + // Trigger the change event to show it updates the property + ((UIEventHandler)frame.AttributeValue)(new UIChangeEventArgs + { + Value = "Mon 2018-03-05" + }); + Assert.Equal(new DateTime(2018, 3, 5), myDateProperty.GetValue(component)); + }, + frame => AssertFrame.Text(frame, "\n", 3)); + } + + [Fact] + public void SupportsTwoWayBindingForBoolValues() + { + // Arrange/Act + var component = CompileToComponent( + @" @functions { public bool MyValue { get; set; } = true; }"); @@ -406,12 +467,11 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test // Assert var frames = GetRenderTree(component); Assert.Collection(frames, - frame => AssertFrame.Element(frame, "input", 4, 0), - frame => AssertFrame.Attribute(frame, "type", "checkbox", 1), - frame => AssertFrame.Attribute(frame, "value", "True", 2), + frame => AssertFrame.Element(frame, "input", 3, 0), + frame => AssertFrame.Attribute(frame, "value", "True", 1), frame => { - AssertFrame.Attribute(frame, "onchange", 3); + AssertFrame.Attribute(frame, "onchange", 2); // Trigger the change event to show it updates the property ((UIEventHandler)frame.AttributeValue)(new UIChangeEventArgs @@ -420,7 +480,38 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test }); Assert.False((bool)myValueProperty.GetValue(component)); }, - frame => AssertFrame.Text(frame, "\n", 4)); + frame => AssertFrame.Text(frame, "\n", 3)); + } + + [Fact] + public void SupportsTwoWayBindingForEnumValues() + { + // Arrange/Act + var myEnumType = FullTypeName(); + var component = CompileToComponent( + $@" + @functions {{ + public {myEnumType} MyValue {{ get; set; }} = {myEnumType}.{nameof(MyEnum.FirstValue)}; + }}"); + var myValueProperty = component.GetType().GetProperty("MyValue"); + + // Assert + var frames = GetRenderTree(component); + Assert.Collection(frames, + frame => AssertFrame.Element(frame, "input", 3, 0), + frame => AssertFrame.Attribute(frame, "value", MyEnum.FirstValue.ToString(), 1), + frame => + { + AssertFrame.Attribute(frame, "onchange", 2); + + // Trigger the change event to show it updates the property + ((UIEventHandler)frame.AttributeValue)(new UIChangeEventArgs + { + Value = MyEnum.SecondValue.ToString() + }); + Assert.Equal(MyEnum.SecondValue, (MyEnum)myValueProperty.GetValue(component)); + }, + frame => AssertFrame.Text(frame, "\n", 3)); } [Fact] @@ -901,6 +992,8 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test public class MyService1Impl : IMyService1 { } public class MyService2Impl : IMyService2 { } + public enum MyEnum { FirstValue, SecondValue } + private static string FullTypeName() => typeof(T).FullName.Replace('+', '.'); }