From 6995b974e9fc50d047f0ba2c36a875c4d950bb43 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 26 Feb 2018 13:40:32 +0000 Subject: [PATCH] Support @bind for textboxes and checkboxes --- .../src/Rendering/BrowserRenderer.ts | 19 ++++- .../BlazorIntermediateNodeWriter.cs | 51 ++++++++++-- .../Components/BlazorComponent.cs | 7 ++ .../RazorCompilerTest.cs | 61 +++++++++++++++ .../Tests/BindTest.cs | 78 +++++++++++++++++++ .../BasicTestApp/BindCasesComponent.cshtml | 31 ++++++++ test/testapps/BasicTestApp/wwwroot/index.html | 1 + 7 files changed, 238 insertions(+), 10 deletions(-) create mode 100644 test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/BindTest.cs create mode 100644 test/testapps/BasicTestApp/BindCasesComponent.cshtml diff --git a/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Rendering/BrowserRenderer.ts b/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Rendering/BrowserRenderer.ts index c457a659bc..b4434ab092 100644 --- a/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Rendering/BrowserRenderer.ts +++ b/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Rendering/BrowserRenderer.ts @@ -186,7 +186,11 @@ export class BrowserRenderer { } case 'onchange': { toDomElement.removeEventListener('change', toDomElement['_blazorChangeListener']); - const listener = evt => raiseEvent(evt, browserRendererId, componentId, eventHandlerId, 'change', { Type: 'change', Value: evt.target.value }); + const targetIsCheckbox = isCheckbox(toDomElement); + const listener = evt => { + const newValue = targetIsCheckbox ? evt.target.checked : evt.target.value; + raiseEvent(evt, browserRendererId, componentId, eventHandlerId, 'change', { Type: 'change', Value: newValue }); + }; toDomElement['_blazorChangeListener'] = listener; toDomElement.addEventListener('change', listener); break; @@ -218,8 +222,13 @@ export class BrowserRenderer { // Certain elements have built-in behaviour for their 'value' property switch (element.tagName) { case 'INPUT': - case 'SELECT': // Note: this doen't handle correctly: https://github.com/aspnet/Blazor/issues/157 + (element as any).value = value; + } return true; default: return false; @@ -244,6 +253,10 @@ export class BrowserRenderer { } } +function isCheckbox(element: Element) { + return element.tagName === 'INPUT' && element.getAttribute('type') === 'checkbox'; +} + function insertNodeIntoDOM(node: Node, parent: Element, childIndex: number) { if (childIndex >= parent.childNodes.length) { parent.appendChild(node); diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorIntermediateNodeWriter.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorIntermediateNodeWriter.cs index 106efb70cd..65ed3cd485 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorIntermediateNodeWriter.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorIntermediateNodeWriter.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Text; +using System.Text.RegularExpressions; using AngleSharp; using AngleSharp.Html; using AngleSharp.Parser.Html; @@ -21,10 +22,11 @@ namespace Microsoft.AspNetCore.Blazor.Razor { // Per the HTML spec, the following elements are inherently self-closing // For example, is the same as (and therefore it cannot contain descendants) - private static HashSet htmlVoidElementsLookup + private readonly static HashSet htmlVoidElementsLookup = new HashSet( 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 ScopeStack _scopeStack = new ScopeStack(); private string _unconsumedHtml; @@ -269,12 +271,7 @@ namespace Microsoft.AspNetCore.Blazor.Razor { foreach (var token in _currentElementAttributeTokens) { - codeWriter - .WriteStartMethodInvocation($"{_scopeStack.BuilderVarName}.{nameof(RenderTreeBuilder.AddAttribute)}") - .Write((_sourceSequence++).ToString()) - .WriteParameterSeparator() - .Write(token.AttributeValue.Content) - .WriteEndMethodInvocation(); + WriteElementAttributeToken(context, nextTag, token); } _currentElementAttributeTokens.Clear(); } @@ -321,6 +318,46 @@ namespace Microsoft.AspNetCore.Blazor.Razor } } + private void WriteElementAttributeToken(CodeRenderingContext context, HtmlTagToken tag, PendingAttributeToken token) + { + 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 }); + + // [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"; + var onChangeAttributeToken = new PendingAttributeToken + { + AttributeValue = new IntermediateToken + { + Kind = TokenKind.CSharp, + Content = $"onchange(_newValue_ => {{ {valueExpression} = ({castToType})_newValue_; }})" + } + }; + WriteElementAttributeToken(context, tag, onChangeAttributeToken); + } + else + { + // For any other attribute token (e.g., @onclick(...)), treat it as an expression + // that will evaluate as an attribute frame + context.CodeWriter + .WriteStartMethodInvocation($"{_scopeStack.BuilderVarName}.{nameof(RenderTreeBuilder.AddAttribute)}") + .Write((_sourceSequence++).ToString()) + .WriteParameterSeparator() + .Write(token.AttributeValue.Content) + .WriteEndMethodInvocation(); + } + } + private void ThrowTemporaryComponentSyntaxError(HtmlContentIntermediateNode node, HtmlTagToken tag, string componentName) => throw new RazorCompilerException( $"Wrong syntax for '{tag.Attributes[0].Key}' on '{componentName}': As a temporary " + diff --git a/src/Microsoft.AspNetCore.Blazor/Components/BlazorComponent.cs b/src/Microsoft.AspNetCore.Blazor/Components/BlazorComponent.cs index 0943c57709..32d11ffa68 100644 --- a/src/Microsoft.AspNetCore.Blazor/Components/BlazorComponent.cs +++ b/src/Microsoft.AspNetCore.Blazor/Components/BlazorComponent.cs @@ -178,6 +178,13 @@ namespace Microsoft.AspNetCore.Blazor.Components public virtual Task ExecuteAsync() => throw new NotImplementedException($"Blazor components do not implement {nameof(ExecuteAsync)}."); + /// + /// Applies two-way data binding between the element and the property. + /// + /// The model property to be bound to the element. + protected RenderTreeFrame bind(object value) + => 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 013f3d74f0..f477631a36 100644 --- a/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs @@ -362,6 +362,67 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test frame => AssertFrame.Whitespace(frame, 2)); } + [Fact] + public void SupportsTwoWayBindingForTextboxes() + { + // Arrange/Act + var component = CompileToComponent( + @" + @functions { + public string MyValue { get; set; } = ""Initial value""; + }"); + 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", "Initial value", 1), + frame => + { + AssertFrame.Attribute(frame, "onchange", 2); + + // Trigger the change event to show it updates the property + ((UIEventHandler)frame.AttributeValue)(new UIChangeEventArgs + { + Value = "Modified value" + }); + Assert.Equal("Modified value", myValueProperty.GetValue(component)); + }, + frame => AssertFrame.Text(frame, "\n", 3)); + } + + [Fact] + public void SupportsTwoWayBindingForCheckboxes() + { + // Arrange/Act + var component = CompileToComponent( + @" + @functions { + public bool MyValue { get; set; } = true; + }"); + var myValueProperty = component.GetType().GetProperty("MyValue"); + + // 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.Attribute(frame, "onchange", 3); + + // Trigger the change event to show it updates the property + ((UIEventHandler)frame.AttributeValue)(new UIChangeEventArgs + { + Value = false + }); + Assert.False((bool)myValueProperty.GetValue(component)); + }, + frame => AssertFrame.Text(frame, "\n", 4)); + } + [Fact] public void SupportsChildComponentsViaTemporarySyntax() { diff --git a/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/BindTest.cs b/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/BindTest.cs new file mode 100644 index 0000000000..99c1dde52e --- /dev/null +++ b/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/BindTest.cs @@ -0,0 +1,78 @@ +// 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 BasicTestApp; +using Microsoft.AspNetCore.Blazor.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Blazor.E2ETest.Infrastructure.ServerFixtures; +using OpenQA.Selenium; +using Xunit; + +namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests +{ + public class BindTest : BasicTestAppTestBase + { + public BindTest(BrowserFixture browserFixture, DevHostServerFixture serverFixture) + : base(browserFixture, serverFixture) + { + Navigate(ServerPathBase, noReload: true); + MountTestComponent(); + } + + [Fact] + public void CanBindTextbox_InitiallyBlank() + { + var target = Browser.FindElement(By.Id("textbox-initially-blank")); + var boundValue = Browser.FindElement(By.Id("textbox-initially-blank-value")); + Assert.Equal(string.Empty, target.GetAttribute("value")); + Assert.Equal(string.Empty, boundValue.Text); + + // Modify target; verify value is updated + target.SendKeys("Changed value"); + Assert.Equal(string.Empty, boundValue.Text); // Doesn't update until change event + target.SendKeys("\t"); + Assert.Equal("Changed value", boundValue.Text); + } + + [Fact] + public void CanBindTextbox_InitiallyPopulated() + { + var target = Browser.FindElement(By.Id("textbox-initially-populated")); + var boundValue = Browser.FindElement(By.Id("textbox-initially-populated-value")); + Assert.Equal("Hello", target.GetAttribute("value")); + Assert.Equal("Hello", boundValue.Text); + + // Modify target; verify value is updated + target.Clear(); + target.SendKeys("Changed value\t"); + Assert.Equal("Changed value", boundValue.Text); + } + + [Fact] + public void CanBindCheckbox_InitiallyUnchecked() + { + var target = Browser.FindElement(By.Id("checkbox-initially-unchecked")); + var boundValue = Browser.FindElement(By.Id("checkbox-initially-unchecked-value")); + Assert.False(target.Selected); + Assert.Equal("False", boundValue.Text); + + // Modify target; verify value is updated + target.Click(); + Assert.True(target.Selected); + Assert.Equal("True", boundValue.Text); + } + + [Fact] + public void CanBindCheckbox_InitiallyChecked() + { + var target = Browser.FindElement(By.Id("checkbox-initially-checked")); + var boundValue = Browser.FindElement(By.Id("checkbox-initially-checked-value")); + Assert.True(target.Selected); + Assert.Equal("True", boundValue.Text); + + // Modify target; verify value is updated + target.Click(); + Assert.False(target.Selected); + Assert.Equal("False", boundValue.Text); + } + } +} diff --git a/test/testapps/BasicTestApp/BindCasesComponent.cshtml b/test/testapps/BasicTestApp/BindCasesComponent.cshtml new file mode 100644 index 0000000000..c8527aa9e1 --- /dev/null +++ b/test/testapps/BasicTestApp/BindCasesComponent.cshtml @@ -0,0 +1,31 @@ +

Textbox

+

+ Initially blank: + + @textboxInitiallyBlankValue +

+

+ Initially populated: + + @textboxInitiallyPopulatedValue +

+ +

Checkbox

+

+ Initially unchecked: + + @checkboxInitiallyUncheckedValue +

+

+ Initially checked: + + @checkboxInitiallyCheckedValue +

+ +@functions { + string textboxInitiallyBlankValue = null; + string textboxInitiallyPopulatedValue = "Hello"; + + bool checkboxInitiallyUncheckedValue = false; + bool checkboxInitiallyCheckedValue = true; +} diff --git a/test/testapps/BasicTestApp/wwwroot/index.html b/test/testapps/BasicTestApp/wwwroot/index.html index 0aaa53b8ef..832c91ebf3 100644 --- a/test/testapps/BasicTestApp/wwwroot/index.html +++ b/test/testapps/BasicTestApp/wwwroot/index.html @@ -22,6 +22,7 @@ +