Support @bind for enum and DateTime values (with optional format string)
This commit is contained in:
parent
3ef78dcb7b
commit
61e07eb615
|
|
@ -12,6 +12,7 @@ using AngleSharp.Parser.Html;
|
||||||
using Microsoft.AspNetCore.Razor.Language;
|
using Microsoft.AspNetCore.Razor.Language;
|
||||||
using Microsoft.AspNetCore.Razor.Language.CodeGeneration;
|
using Microsoft.AspNetCore.Razor.Language.CodeGeneration;
|
||||||
using Microsoft.AspNetCore.Razor.Language.Intermediate;
|
using Microsoft.AspNetCore.Razor.Language.Intermediate;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
|
||||||
namespace Microsoft.AspNetCore.Blazor.Razor
|
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" },
|
new[] { "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr" },
|
||||||
StringComparer.OrdinalIgnoreCase);
|
StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly static Regex bindExpressionRegex = new Regex(@"^bind\((.+)\)$");
|
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 readonly ScopeStack _scopeStack = new ScopeStack();
|
||||||
private string _unconsumedHtml;
|
private string _unconsumedHtml;
|
||||||
|
|
@ -323,24 +326,31 @@ namespace Microsoft.AspNetCore.Blazor.Razor
|
||||||
var bindMatch = bindExpressionRegex.Match(token.AttributeValue.Content);
|
var bindMatch = bindExpressionRegex.Match(token.AttributeValue.Content);
|
||||||
if (bindMatch.Success)
|
if (bindMatch.Success)
|
||||||
{
|
{
|
||||||
// The @bind(X) syntax is special. We convert it to a pair of attributes:
|
// TODO: Consider alternatives to the @bind syntax. The following is very strange.
|
||||||
// [1] value=@X
|
|
||||||
var valueExpression = bindMatch.Groups[1].Value;
|
|
||||||
WriteAttribute(context.CodeWriter, "value",
|
|
||||||
new IntermediateToken { Kind = TokenKind.CSharp, Content = valueExpression });
|
|
||||||
|
|
||||||
// [2] @onchange(newValue => { X = newValue; })
|
// The @bind(X, Y, Z, ...) syntax is special. We convert it to a pair of attributes:
|
||||||
var isCheckbox = tag.Data.Equals("input", StringComparison.OrdinalIgnoreCase)
|
// [1] value=@BindMethods.GetValue(X, Y, Z, ...)
|
||||||
&& tag.Attributes.Any(a =>
|
var valueParams = bindMatch.Groups[1].Value;
|
||||||
a.Key.Equals("type", StringComparison.Ordinal)
|
WriteAttribute(context.CodeWriter, "value", new IntermediateToken
|
||||||
&& a.Value.Equals("checkbox", StringComparison.Ordinal));
|
{
|
||||||
var castToType = isCheckbox ? "bool" : "string";
|
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
|
var onChangeAttributeToken = new PendingAttributeToken
|
||||||
{
|
{
|
||||||
AttributeValue = new IntermediateToken
|
AttributeValue = new IntermediateToken
|
||||||
{
|
{
|
||||||
Kind = TokenKind.CSharp,
|
Kind = TokenKind.CSharp,
|
||||||
Content = $"onchange(_newValue_ => {{ {valueExpression} = ({castToType})_newValue_; }})"
|
Content = $"onchange({RenderTreeBuilder.BindMethodsSetValue}({parsedArgsJoined}))"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
WriteElementAttributeToken(context, tag, onChangeAttributeToken);
|
WriteElementAttributeToken(context, tag, onChangeAttributeToken);
|
||||||
|
|
|
||||||
|
|
@ -24,5 +24,9 @@ namespace Microsoft.AspNetCore.Blazor.Razor
|
||||||
public static readonly string GetFrames = nameof(GetFrames);
|
public static readonly string GetFrames = nameof(GetFrames);
|
||||||
|
|
||||||
public static readonly string ChildContent = nameof(ChildContent);
|
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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Methods used internally by @bind syntax. Not intended to be used directly.
|
||||||
|
/// </summary>
|
||||||
|
public static class BindMethods
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Not intended to be used directly.
|
||||||
|
/// </summary>
|
||||||
|
public static T GetValue<T>(T value) => value;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Not intended to be used directly.
|
||||||
|
/// </summary>
|
||||||
|
public static string GetValue(DateTime value, string format) =>
|
||||||
|
value == default ? null
|
||||||
|
: (format == null ? value.ToString() : value.ToString(format));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Not intended to be used directly.
|
||||||
|
/// </summary>
|
||||||
|
public static Action<object> SetValue(Action<string> setter, string existingValue)
|
||||||
|
=> objValue => setter((string)objValue);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Not intended to be used directly.
|
||||||
|
/// </summary>
|
||||||
|
public static Action<object> SetValue(Action<bool> setter, bool existingValue)
|
||||||
|
=> objValue => setter((bool)objValue);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Not intended to be used directly.
|
||||||
|
/// </summary>
|
||||||
|
public static Action<object> SetValue<T>(Action<T> 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.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Not intended to be used directly.
|
||||||
|
/// </summary>
|
||||||
|
public static Action<object> SetValue(Action<DateTime> setter, DateTime existingValue)
|
||||||
|
=> objValue => SetDateTimeValue(setter, objValue, null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Not intended to be used directly.
|
||||||
|
/// </summary>
|
||||||
|
public static Action<object> SetValue(Action<DateTime> setter, DateTime existingValue, string format)
|
||||||
|
=> objValue => SetDateTimeValue(setter, objValue, format);
|
||||||
|
|
||||||
|
private static void SetDateTimeValue(Action<DateTime> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -185,6 +185,13 @@ namespace Microsoft.AspNetCore.Blazor.Components
|
||||||
protected RenderTreeFrame bind(object value)
|
protected RenderTreeFrame bind(object value)
|
||||||
=> throw new NotImplementedException($"{nameof(bind)} is a compile-time symbol only and should not be invoked.");
|
=> throw new NotImplementedException($"{nameof(bind)} is a compile-time symbol only and should not be invoked.");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies two-way data binding between the element and the property.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The model property to be bound to the element.</param>
|
||||||
|
protected RenderTreeFrame bind(DateTime value, string format)
|
||||||
|
=> throw new NotImplementedException($"{nameof(bind)} is a compile-time symbol only and should not be invoked.");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles click events by invoking <paramref name="handler"/>.
|
/// Handles click events by invoking <paramref name="handler"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -393,11 +393,72 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SupportsTwoWayBindingForCheckboxes()
|
public void SupportsTwoWayBindingForDateValues()
|
||||||
{
|
{
|
||||||
// Arrange/Act
|
// Arrange/Act
|
||||||
var component = CompileToComponent(
|
var component = CompileToComponent(
|
||||||
@"<input @bind(MyValue) type=""checkbox"" />
|
@"<input @bind(MyDate) />
|
||||||
|
@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(
|
||||||
|
@"<input @bind(MyDate, ""ddd yyyy-MM-dd"") />
|
||||||
|
@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(
|
||||||
|
@"<input @bind(MyValue) />
|
||||||
@functions {
|
@functions {
|
||||||
public bool MyValue { get; set; } = true;
|
public bool MyValue { get; set; } = true;
|
||||||
}");
|
}");
|
||||||
|
|
@ -406,12 +467,11 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test
|
||||||
// Assert
|
// Assert
|
||||||
var frames = GetRenderTree(component);
|
var frames = GetRenderTree(component);
|
||||||
Assert.Collection(frames,
|
Assert.Collection(frames,
|
||||||
frame => AssertFrame.Element(frame, "input", 4, 0),
|
frame => AssertFrame.Element(frame, "input", 3, 0),
|
||||||
frame => AssertFrame.Attribute(frame, "type", "checkbox", 1),
|
frame => AssertFrame.Attribute(frame, "value", "True", 1),
|
||||||
frame => AssertFrame.Attribute(frame, "value", "True", 2),
|
|
||||||
frame =>
|
frame =>
|
||||||
{
|
{
|
||||||
AssertFrame.Attribute(frame, "onchange", 3);
|
AssertFrame.Attribute(frame, "onchange", 2);
|
||||||
|
|
||||||
// Trigger the change event to show it updates the property
|
// Trigger the change event to show it updates the property
|
||||||
((UIEventHandler)frame.AttributeValue)(new UIChangeEventArgs
|
((UIEventHandler)frame.AttributeValue)(new UIChangeEventArgs
|
||||||
|
|
@ -420,7 +480,38 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test
|
||||||
});
|
});
|
||||||
Assert.False((bool)myValueProperty.GetValue(component));
|
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<MyEnum>();
|
||||||
|
var component = CompileToComponent(
|
||||||
|
$@"<input @bind(MyValue) />
|
||||||
|
@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]
|
[Fact]
|
||||||
|
|
@ -901,6 +992,8 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test
|
||||||
public class MyService1Impl : IMyService1 { }
|
public class MyService1Impl : IMyService1 { }
|
||||||
public class MyService2Impl : IMyService2 { }
|
public class MyService2Impl : IMyService2 { }
|
||||||
|
|
||||||
|
public enum MyEnum { FirstValue, SecondValue }
|
||||||
|
|
||||||
private static string FullTypeName<T>()
|
private static string FullTypeName<T>()
|
||||||
=> typeof(T).FullName.Replace('+', '.');
|
=> typeof(T).FullName.Replace('+', '.');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue