From 5b658c80a1ef5cbce1443580216b9efba8cfe51c Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Mon, 12 Mar 2018 17:28:47 -0700 Subject: [PATCH] Replace @bind with bind-... This change introduces a 'tag helper' that replaces @bind with custom code generation that accomplishes roughly the same thing. This feature lights up by dynamically generating tag helpers that are visible to tooling and affect the code generation based on: - pattern recognition of component properties - attributes that create definitions for elements - a 'fallback' case for elements 'bind' also supports format strings (currently only for DateTime) via a separate attribute. This change introduces the basic framework for bind and tooling support. We know that we'll have to do more work to define the set of default 'bind' cases for the DOM and to flesh out the conversion/formatting infrastructure. This change gets us far enough to replace all of the cases we currently have tests for :) with the new features. The old @bind technique still works for now. Examples: @* bind an input element to an expression *@ @functions { public DateTime SelectedDate { get; set; } } @* bind an arbitrary expression to an arbitrary set of attributes *@
...
@* write a component that supports bind *@ @* in Counter.cshtml *@
...html omitted for brevity...
@functions { public int Value { get; set; } = 1; public Action ValueChanged { get; set; } } @* in another file *@ @functions { public int CurrentValue { get; set; } } --- .../BindLoweringPass.cs | 418 +++++++++++ .../BindTagHelperDescriptorProvider.cs | 491 +++++++++++++ .../BlazorApi.cs | 18 +- .../BlazorDiagnosticFactory.cs | 17 + .../BlazorExtensionInitializer.cs | 7 +- .../BlazorMetadata.cs | 39 + .../ComponentAttributeExtensionNode.cs | 25 + .../ComponentLoweringPass.cs | 25 +- .../ComponentTagHelperDescriptorProvider.cs | 10 +- .../OrphanTagHelperLoweringPass.cs | 283 ++++++++ .../Resources.Designer.cs | 45 ++ .../Resources.resx | 15 + ...elperBoundAttributeDescriptorExtensions.cs | 2 +- .../TagHelperDescriptorExtensions.cs | 94 +++ .../Components/BindAttributes.cs | 26 + .../Components/BindElementAttribute.cs | 42 ++ .../Components/BindInputElementAttribute.cs | 37 + .../Components/BindMethods.cs | 64 ++ .../BindRazorIntegrationTest.cs | 491 +++++++++++++ .../ComponentRenderingRazorIntegrationTest.cs | 3 +- .../RenderingRazorIntegrationTest.cs | 16 +- .../BaseTagHelperDescriptorProviderTest.cs | 44 ++ .../BindTagHelperDescriptorProviderTest.cs | 682 ++++++++++++++++++ ...omponentTagHelperDescriptorProviderTest.cs | 36 +- test/shared/AssertFrame.cs | 6 + .../BasicTestApp/BindCasesComponent.cshtml | 10 +- 26 files changed, 2879 insertions(+), 67 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BindLoweringPass.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BindTagHelperDescriptorProvider.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorMetadata.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Razor.Extensions/OrphanTagHelperLoweringPass.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Razor.Extensions/TagHelperDescriptorExtensions.cs create mode 100644 src/Microsoft.AspNetCore.Blazor/Components/BindAttributes.cs create mode 100644 src/Microsoft.AspNetCore.Blazor/Components/BindElementAttribute.cs create mode 100644 src/Microsoft.AspNetCore.Blazor/Components/BindInputElementAttribute.cs create mode 100644 test/Microsoft.AspNetCore.Blazor.Build.Test/BindRazorIntegrationTest.cs create mode 100644 test/Microsoft.AspNetCore.Blazor.Razor.Extensions.Test/BaseTagHelperDescriptorProviderTest.cs create mode 100644 test/Microsoft.AspNetCore.Blazor.Razor.Extensions.Test/BindTagHelperDescriptorProviderTest.cs diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BindLoweringPass.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BindLoweringPass.cs new file mode 100644 index 0000000000..6f96bb66ed --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BindLoweringPass.cs @@ -0,0 +1,418 @@ +// 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.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Extensions; +using Microsoft.AspNetCore.Razor.Language.Intermediate; + +namespace Microsoft.AspNetCore.Blazor.Razor +{ + internal class BindLoweringPass : IntermediateNodePassBase, IRazorOptimizationPass + { + protected override void ExecuteCore(RazorCodeDocument codeDocument, DocumentIntermediateNode documentNode) + { + var @namespace = documentNode.FindPrimaryNamespace(); + var @class = documentNode.FindPrimaryClass(); + if (@namespace == null || @class == null) + { + // Nothing to do, bail. We can't function without the standard structure. + return; + } + + // For each bind *usage* we need to rewrite the tag helper node to map to basic contstructs. + var nodes = documentNode.FindDescendantNodes(); + for (var i = 0; i < nodes.Count; i++) + { + var node = nodes[i]; + + ProcessDuplicates(node); + + for (var j = node.Children.Count - 1; j >= 0; j--) + { + var attributeNode = node.Children[j] as ComponentAttributeExtensionNode; + if (attributeNode != null && + attributeNode.TagHelper != null && + attributeNode.TagHelper.IsBindTagHelper()) + { + RewriteUsage(node, j, attributeNode); + } + } + } + } + + private void ProcessDuplicates(TagHelperIntermediateNode node) + { + // Reverse order because we will remove nodes. + // + // Each 'property' node could be duplicated if there are multiple tag helpers that match that + // particular attribute. This is common in our approach, which relies on 'fallback' tag helpers + // that overlap with more specific ones. + for (var i = node.Children.Count - 1; i >= 0; i--) + { + // For each usage of the general 'fallback' bind tag helper, it could duplicate + // the usage of a more specific one. Look for duplicates and remove the fallback. + var attributeNode = node.Children[i] as ComponentAttributeExtensionNode; + if (attributeNode != null && + attributeNode.TagHelper != null && + attributeNode.TagHelper.IsFallbackBindTagHelper()) + { + for (var j = 0; j < node.Children.Count; j++) + { + var duplicate = node.Children[j] as ComponentAttributeExtensionNode; + if (duplicate != null && + duplicate.TagHelper != null && + duplicate.TagHelper.IsBindTagHelper() && + duplicate.AttributeName == attributeNode.AttributeName && + !object.ReferenceEquals(attributeNode, duplicate)) + { + // Found a duplicate - remove the 'fallback' in favor of the + // more specific tag helper. + node.Children.RemoveAt(i); + node.TagHelpers.Remove(attributeNode.TagHelper); + break; + } + } + } + + // Also treat the general as a 'fallback' for that case and remove it. + // This is a workaround for a limitation where you can't write a tag helper that binds only + // when a specific attribute is **not** present. + if (attributeNode != null && + attributeNode.TagHelper != null && + attributeNode.TagHelper.IsInputElementFallbackBindTagHelper()) + { + for (var j = 0; j < node.Children.Count; j++) + { + var duplicate = node.Children[j] as ComponentAttributeExtensionNode; + if (duplicate != null && + duplicate.TagHelper != null && + duplicate.TagHelper.IsInputElementBindTagHelper() && + duplicate.AttributeName == attributeNode.AttributeName && + !object.ReferenceEquals(attributeNode, duplicate)) + { + // Found a duplicate - remove the 'fallback' input tag helper in favor of the + // more specific tag helper. + node.Children.RemoveAt(i); + node.TagHelpers.Remove(attributeNode.TagHelper); + break; + } + } + } + } + + // If we still have duplicates at this point then they are genuine conflicts. + var duplicates = node.Children + .OfType() + .GroupBy(p => p.AttributeName) + .Where(g => g.Count() > 1); + + foreach (var duplicate in duplicates) + { + node.Diagnostics.Add(BlazorDiagnosticFactory.CreateBindAttribute_Duplicates( + node.Source, + duplicate.Key, + duplicate.ToArray())); + foreach (var property in duplicate) + { + node.Children.Remove(property); + } + } + } + + private void RewriteUsage(TagHelperIntermediateNode node, int index, ComponentAttributeExtensionNode attributeNode) + { + // Bind works similarly to a macro, it always expands to code that the user could have written. + // + // For the nodes that are related to the bind-attribute rewrite them to look like a pair of + // 'normal' HTML attributes similar to the following transformation. + // + // Input: + // Output: + // + // This means that the expression that appears inside of 'bind' must be an LValue or else + // there will be errors. In general the errors that come from C# in this case are good enough + // to understand the problem. + // + // The BindMethods calls are required in this case because to give us a good experience. They + // use overloading to ensure that can get an Action that will convert and set an arbitrary + // value. + // + // We also assume that the element will be treated as a component for now because + // multiple passes handle 'special' tag helpers. We have another pass that translates + // a tag helper node back into 'regular' element when it doesn't have an associated component + if (!TryComputeAttributeNames( + node, + attributeNode.AttributeName, + out var valueAttributeName, + out var changeAttributeName, + out var valueAttribute, + out var changeAttribute)) + { + // Skip anything we can't understand. It's important that we don't crash, that will bring down + // the build. + return; + } + + var originalContent = GetAttributeContent(attributeNode); + if (string.IsNullOrEmpty(originalContent)) + { + // This can happen in error cases, the parser will already have flagged this + // as an error, so ignore it. + return; + } + + // Look for a matching format node. If we find one then we need to pass the format into the + // two nodes we generate. + string format = null; + if (TryGetFormatNode(node, + attributeNode, + valueAttributeName, + out var formatNode)) + { + // Don't write the format out as its own attribute; + node.Children.Remove(formatNode); + format = GetAttributeContent(formatNode); + } + + var valueAttributeNode = new ComponentAttributeExtensionNode(attributeNode) + { + AttributeName = valueAttributeName, + BoundAttribute = valueAttribute, // Might be null if it doesn't match a component attribute + PropertyName = valueAttribute?.GetPropertyName(), + TagHelper = valueAttribute == null ? null : attributeNode.TagHelper, + }; + node.Children.Insert(index, valueAttributeNode); + + // Now rewrite the content of the value node to look like: + // + // BindMethods.GetValue() OR + // BindMethods.GetValue(, ) + // + // For now, the way this is done isn't debuggable. But since the expression + // passed here must be an LValue, it's probably not important. + var valueNodeContent = format == null ? + $"{BlazorApi.BindMethods.GetValue}({originalContent})" : + $"{BlazorApi.BindMethods.GetValue}({originalContent}, {format})"; + valueAttributeNode.Children.Clear(); + valueAttributeNode.Children.Add(new CSharpExpressionIntermediateNode() + { + Children = + { + new IntermediateToken() + { + Content = valueNodeContent, + Kind = TokenKind.CSharp + }, + }, + }); + + var changeAttributeNode = new ComponentAttributeExtensionNode(attributeNode) + { + AttributeName = changeAttributeName, + BoundAttribute = changeAttribute, // Might be null if it doesn't match a component attribute + PropertyName = changeAttribute?.GetPropertyName(), + TagHelper = changeAttribute == null ? null : attributeNode.TagHelper, + }; + node.Children[index + 1] = changeAttributeNode; + + // Now rewrite the content of the change-handler node. There are two cases we care about + // here. If it's a component attribute, then don't use the 'BindMethods wrapper. We expect + // component attributes to always 'match' on type. + // + // __value => = __value + // + // For general DOM attributes, we need to be able to create a delegate that accepts UIEventArgs + // so we use BindMethods.SetValueHandler + // + // BindMethods.SetValueHandler(__value => = __value, ) OR + // BindMethods.SetValueHandler(__value => = __value, , ) + // + // For now, the way this is done isn't debuggable. But since the expression + // passed here must be an LValue, it's probably not important. + string changeAttributeContent = null; + if (changeAttributeNode.BoundAttribute == null && format == null) + { + changeAttributeContent = $"{BlazorApi.BindMethods.SetValueHandler}(__value => {originalContent} = __value, {originalContent})"; + } + else if (changeAttributeNode.BoundAttribute == null && format != null) + { + changeAttributeContent = $"{BlazorApi.BindMethods.SetValueHandler}(__value => {originalContent} = __value, {originalContent}, {format})"; + } + else + { + changeAttributeContent = $"__value => {originalContent} = __value"; + } + + changeAttributeNode.Children.Clear(); + changeAttributeNode.Children.Add(new CSharpExpressionIntermediateNode() + { + Children = + { + new IntermediateToken() + { + Content = changeAttributeContent, + Kind = TokenKind.CSharp + }, + }, + }); + } + + private bool TryParseBindAttribute( + string attributeName, + out string valueAttributeName, + out string changeAttributeName) + { + valueAttributeName = null; + changeAttributeName = null; + + if (!attributeName.StartsWith("bind")) + { + return false; + } + + if (attributeName == "bind") + { + return true; + } + + var segments = attributeName.Split('-'); + for (var i = 0; i < segments.Length; i++) + { + if (string.IsNullOrEmpty(segments[0])) + { + return false; + } + } + + switch (segments.Length) + { + case 2: + valueAttributeName = segments[1]; + return true; + + case 3: + changeAttributeName = segments[2]; + valueAttributeName = segments[1]; + return true; + + default: + return false; + } + } + + // Attempts to compute the attribute names that should be used for an instance of 'bind'. + private bool TryComputeAttributeNames( + TagHelperIntermediateNode node, + string attributeName, + out string valueAttributeName, + out string changeAttributeName, + out BoundAttributeDescriptor valueAttribute, + out BoundAttributeDescriptor changeAttribute) + { + valueAttribute = null; + changeAttribute = null; + + // Even though some of our 'bind' tag helpers specify the attribute names, they + // should still satisfy one of the valid syntaxes. + if (!TryParseBindAttribute(attributeName, out valueAttributeName, out changeAttributeName)) + { + return false; + } + + // The the tag helper specifies attribute names, they should win. + // + // This handles cases like where the tag helper is + // generated to match a specific tag and has metadata that identify the attributes. + // + // We expect 1 bind tag helper per-node. + var bindTagHelper = node.TagHelpers.Single(t => t.IsBindTagHelper()); + valueAttributeName = bindTagHelper.GetValueAttributeName() ?? valueAttributeName; + changeAttributeName = bindTagHelper.GetChangeAttributeName() ?? changeAttributeName; + + // We expect 0-1 components per-node. + var componentTagHelper = node.TagHelpers.FirstOrDefault(t => t.IsComponentTagHelper()); + if (componentTagHelper == null) + { + // If it's not a component node then there isn't too much else to figure out. + return attributeName != null && changeAttributeName != null; + } + + // If this is a component, we need an attribute name for the value. + if (attributeName == null) + { + return false; + } + + // If this is a component, then we can infer 'Changed' as the name + // of the change event. + if (changeAttributeName == null) + { + changeAttributeName = valueAttributeName + "Changed"; + } + + for (var i = 0; i < componentTagHelper.BoundAttributes.Count; i++) + { + var attribute = componentTagHelper.BoundAttributes[i]; + + if (string.Equals(valueAttributeName, attribute.Name)) + { + valueAttribute = attribute; + } + + if (string.Equals(changeAttributeName, attribute.Name)) + { + changeAttribute = attribute; + } + } + + return true; + } + + private bool TryGetFormatNode( + TagHelperIntermediateNode node, + ComponentAttributeExtensionNode attributeNode, + string valueAttributeName, + out ComponentAttributeExtensionNode formatNode) + { + for (var i = 0; i < node.Children.Count; i++) + { + var child = node.Children[i] as ComponentAttributeExtensionNode; + if (child != null && + child.TagHelper != null && + child.TagHelper == attributeNode.TagHelper && + child.AttributeName == "format-" + valueAttributeName) + { + formatNode = child; + return true; + } + } + + formatNode = null; + return false; + } + + private static string GetAttributeContent(ComponentAttributeExtensionNode node) + { + if (node.Children[0] is HtmlContentIntermediateNode htmlContentNode) + { + // This case can be hit for a 'string' attribute. We want to turn it into + // an expression. + return "\"" + ((IntermediateToken)htmlContentNode.Children.Single()).Content + "\""; + } + else if (node.Children[0] is CSharpExpressionIntermediateNode cSharpNode) + { + // This case can be hit when the attribute has an explicit @ inside, which + // 'escapes' any special sugar we provide for codegen. + return ((IntermediateToken)cSharpNode.Children.Single()).Content; + } + else + { + // This is the common case for 'mixed' content + return ((IntermediateToken)node.Children.Single()).Content; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BindTagHelperDescriptorProvider.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BindTagHelperDescriptorProvider.cs new file mode 100644 index 0000000000..bd8128661f --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BindTagHelperDescriptorProvider.cs @@ -0,0 +1,491 @@ +// 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.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Razor; + +namespace Microsoft.AspNetCore.Blazor.Razor +{ + internal class BindTagHelperDescriptorProvider : ITagHelperDescriptorProvider + { + // Run after the component tag helper provider, because we need to see the results. + public int Order { get; set; } = 1000; + + public RazorEngine Engine { get; set; } + + public void Execute(TagHelperDescriptorProviderContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + // This provider returns tag helper information for 'bind' which doesn't necessarily + // map to any real component. Bind behaviors more like a macro, which can map a single LValue to + // both a 'value' attribute and a 'value changed' attribute. + // + // User types: + // + // + // We generate: + // + // + // This isn't very different from code the user could write themselves - thus the pronouncement + // that bind is very much like a macro. + // + // A lot of the value that provide in this case is that the associations between the + // elements, and the attributes aren't straightforward. + // + // For instance on we need to listen to 'value' and 'onchange', + // but on + // and so we have a special case for input elements and their type attributes. + // + // 4. For components, we have a bit of a special case. We can infer a syntax that matches + // case #2 based on property names. So if a component provides both 'Value' and 'ValueChanged' + // we will turn that into an instance of bind. + // + // So case #1 here is the most general case. Case #2 and #3 are data-driven based on attribute data + // we have. Case #4 is data-driven based on component definitions. + // + // We provide a good set of attributes that map to the HTML dom. This set is user extensible. + var compilation = context.GetCompilation(); + if (compilation == null) + { + return; + } + + var bindMethods = compilation.GetTypeByMetadataName(BlazorApi.BindMethods.FullTypeName); + if (bindMethods == null) + { + // If we can't find BindMethods, then just bail. We won't be able to compile the + // generated code anyway. + return; + } + + // Tag Helper defintion for case #1. This is the most general case. + context.Results.Add(CreateFallbackBindTagHelper()); + + // For case #2 & #3 we have a whole bunch of attribute entries on BindMethods that we can use + // to data-drive the definitions of these tag helpers. + var elementBindData = GetElementBindData(compilation); + + // Case #2 & #3 + foreach (var tagHelper in CreateElementBindTagHelpers(elementBindData)) + { + context.Results.Add(tagHelper); + } + + // For case #4 we look at the tag helpers that were already created corresponding to components + // and pattern match on properties. + foreach (var tagHelper in CreateComponentBindTagHelpers(context.Results)) + { + context.Results.Add(tagHelper); + } + } + + private TagHelperDescriptor CreateFallbackBindTagHelper() + { + var builder = TagHelperDescriptorBuilder.Create(BlazorMetadata.Bind.TagHelperKind, "Bind", BlazorApi.AssemblyName); + builder.Documentation = Resources.BindTagHelper_Fallback_Documentation; + + builder.Metadata.Add(BlazorMetadata.SpecialKindKey, BlazorMetadata.Bind.TagHelperKind); + builder.Metadata[TagHelperMetadata.Runtime.Name] = BlazorMetadata.Bind.RuntimeName; + builder.Metadata[BlazorMetadata.Bind.FallbackKey] = bool.TrueString; + + // WTE has a bug in 15.7p1 where a Tag Helper without a display-name that looks like + // a C# property will crash trying to create the toolips. + builder.SetTypeName("Microsoft.AspNetCore.Blazor.Components.Bind"); + + builder.TagMatchingRule(rule => + { + rule.TagName = "*"; + rule.Attribute(attribute => + { + attribute.Name = "bind-"; + attribute.NameComparisonMode = RequiredAttributeDescriptor.NameComparisonMode.PrefixMatch; + }); + }); + + builder.BindAttribute(attribute => + { + attribute.Documentation = Resources.BindTagHelper_Fallback_Documentation; + + attribute.Name = "bind-..."; + attribute.AsDictionary("bind-", typeof(object).FullName); + + // WTE has a bug 15.7p1 where a Tag Helper without a display-name that looks like + // a C# property will crash trying to create the toolips. + attribute.SetPropertyName("Bind"); + attribute.TypeName = "System.Collections.Generic.Dictionary"; + }); + + builder.BindAttribute(attribute => + { + attribute.Documentation = Resources.BindTagHelper_Fallback_Format_Documentation; + + attribute.Name = "format-..."; + attribute.AsDictionary("format-", typeof(string).FullName); + + // WTE has a bug 15.7p1 where a Tag Helper without a display-name that looks like + // a C# property will crash trying to create the toolips. + attribute.SetPropertyName("Format"); + attribute.TypeName = "System.Collections.Generic.Dictionary"; + }); + + return builder.Build(); + } + + private List GetElementBindData(Compilation compilation) + { + var bindElement = compilation.GetTypeByMetadataName(BlazorApi.BindElementAttribute.FullTypeName); + var bindInputElement = compilation.GetTypeByMetadataName(BlazorApi.BindInputElementAttribute.FullTypeName); + + if (bindElement == null || bindInputElement == null) + { + // This won't likely happen, but just in case. + return new List(); + } + + var types = new List(); + var visitor = new BindElementDataVisitor(types); + + // Visit the primary output of this compilation, as well as all references. + visitor.Visit(compilation.Assembly); + foreach (var reference in compilation.References) + { + // We ignore .netmodules here - there really isn't a case where they are used by user code + // even though the Roslyn APIs all support them. + if (compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol assembly) + { + visitor.Visit(assembly); + } + } + + var results = new List(); + + for (var i = 0; i < types.Count; i++) + { + var type = types[i]; + var attributes = type.GetAttributes(); + + // Not handling duplicates here for now since we're the primary ones extending this. + // If we see users adding to the set of 'bind' constructs we will want to add deduplication + // and potentially diagnostics. + for (var j = 0; j < attributes.Length; j++) + { + var attribute = attributes[j]; + + if (attribute.AttributeClass == bindElement) + { + results.Add(new ElementBindData( + type.ContainingAssembly.Name, + type.ToDisplayString(), + (string)attribute.ConstructorArguments[0].Value, + null, + (string)attribute.ConstructorArguments[1].Value, + (string)attribute.ConstructorArguments[2].Value, + (string)attribute.ConstructorArguments[3].Value)); + } + else if (attribute.AttributeClass == bindInputElement) + { + results.Add(new ElementBindData( + type.ContainingAssembly.Name, + type.ToDisplayString(), + "input", + (string)attribute.ConstructorArguments[0].Value, + (string)attribute.ConstructorArguments[1].Value, + (string)attribute.ConstructorArguments[2].Value, + (string)attribute.ConstructorArguments[3].Value)); + } + } + } + + return results; + } + private List CreateElementBindTagHelpers(List data) + { + var results = new List(); + + for (var i = 0; i < data.Count; i++) + { + var entry = data[i]; + + var name = entry.Suffix == null ? "Bind" : "Bind_" + entry.Suffix; + var attributeName = entry.Suffix == null ? "bind" : "bind-" + entry.Suffix; + + var formatName = entry.Suffix == null ? "Format_" + entry.ValueAttribute : "Format_" + entry.Suffix; + var formatAttributeName = entry.Suffix == null ? "format-" + entry.ValueAttribute : "format-" + entry.Suffix; + + var builder = TagHelperDescriptorBuilder.Create(BlazorMetadata.Bind.TagHelperKind, name, entry.Assembly); + builder.Documentation = string.Format( + Resources.BindTagHelper_Element_Documentation, + entry.ValueAttribute, + entry.ChangeAttribute); + + builder.Metadata.Add(BlazorMetadata.SpecialKindKey, BlazorMetadata.Bind.TagHelperKind); + builder.Metadata[TagHelperMetadata.Runtime.Name] = BlazorMetadata.Bind.RuntimeName; + builder.Metadata[BlazorMetadata.Bind.ValueAttribute] = entry.ValueAttribute; + builder.Metadata[BlazorMetadata.Bind.ChangeAttribute] = entry.ChangeAttribute; + + if (entry.TypeAttribute != null) + { + // For entries that map to the element, we need to be able to know + // the difference between and for which we + // want to use the same attributes. + // + // We provide a tag helper for that should match all input elements, + // but we only want it to be used when a more specific one is used. + // + // Therefore we use this metadata to know which one is more specific when two + // tag helpers match. + builder.Metadata[BlazorMetadata.Bind.TypeAttribute] = entry.TypeAttribute; + } + + // WTE has a bug in 15.7p1 where a Tag Helper without a display-name that looks like + // a C# property will crash trying to create the toolips. + builder.SetTypeName(entry.TypeName); + + builder.TagMatchingRule(rule => + { + rule.TagName = entry.Element; + if (entry.TypeAttribute != null) + { + rule.Attribute(a => + { + a.Name = "type"; + a.NameComparisonMode = RequiredAttributeDescriptor.NameComparisonMode.FullMatch; + a.Value = entry.TypeAttribute; + a.ValueComparisonMode = RequiredAttributeDescriptor.ValueComparisonMode.FullMatch; + }); + } + + rule.Attribute(a => + { + a.Name = attributeName; + a.NameComparisonMode = RequiredAttributeDescriptor.NameComparisonMode.FullMatch; + }); + }); + + builder.BindAttribute(a => + { + a.Documentation = string.Format( + Resources.BindTagHelper_Element_Documentation, + entry.ValueAttribute, + entry.ChangeAttribute); + + a.Name = attributeName; + a.TypeName = typeof(object).FullName; + + // WTE has a bug 15.7p1 where a Tag Helper without a display-name that looks like + // a C# property will crash trying to create the toolips. + a.SetPropertyName(name); + }); + + builder.BindAttribute(attribute => + { + attribute.Documentation = string.Format(Resources.BindTagHelper_Element_Format_Documentation, attributeName); + + attribute.Name = formatAttributeName; + attribute.TypeName = "System.String"; + + // WTE has a bug 15.7p1 where a Tag Helper without a display-name that looks like + // a C# property will crash trying to create the toolips. + attribute.SetPropertyName(formatName); + }); + + results.Add(builder.Build()); + } + + return results; + } + + private List CreateComponentBindTagHelpers(ICollection tagHelpers) + { + var results = new List(); + + foreach (var tagHelper in tagHelpers) + { + if (!tagHelper.IsComponentTagHelper()) + { + continue; + } + + // We want to create a 'bind' tag helper everywhere we see a pair of properties like `Foo`, `FooChanged` + // where `FooChanged` is a delegate and `Foo` is not. + // + // The easiest way to figure this out without a lot of backtracking is to look for `FooChanged` and then + // try to find a matching "Foo". + for (var i = 0; i < tagHelper.BoundAttributes.Count; i++) + { + var changeAttribute = tagHelper.BoundAttributes[i]; + if (!changeAttribute.Name.EndsWith("Changed") || !changeAttribute.IsDelegateProperty()) + { + continue; + } + + BoundAttributeDescriptor valueAttribute = null; + var valueAttributeName = changeAttribute.Name.Substring(0, changeAttribute.Name.Length - "Changed".Length); + for (var j = 0; j < tagHelper.BoundAttributes.Count; j++) + { + if (tagHelper.BoundAttributes[j].Name == valueAttributeName && !tagHelper.BoundAttributes[j].IsDelegateProperty()) + { + valueAttribute = tagHelper.BoundAttributes[j]; + break; + } + } + + if (valueAttribute == null) + { + // No matching attribute found. + continue; + } + + var builder = TagHelperDescriptorBuilder.Create(BlazorMetadata.Bind.TagHelperKind, tagHelper.Name, tagHelper.AssemblyName); + builder.DisplayName = tagHelper.DisplayName; + builder.Documentation = string.Format( + Resources.BindTagHelper_Component_Documentation, + valueAttribute.Name, + changeAttribute.Name); + + builder.Metadata.Add(BlazorMetadata.SpecialKindKey, BlazorMetadata.Bind.TagHelperKind); + builder.Metadata[TagHelperMetadata.Runtime.Name] = BlazorMetadata.Bind.RuntimeName; + builder.Metadata[BlazorMetadata.Bind.ValueAttribute] = valueAttribute.Name; + builder.Metadata[BlazorMetadata.Bind.ChangeAttribute] = changeAttribute.Name; + + // WTE has a bug 15.7p1 where a Tag Helper without a display-name that looks like + // a C# property will crash trying to create the toolips. + builder.SetTypeName(tagHelper.GetTypeName()); + + // Match the component and attribute name + builder.TagMatchingRule(rule => + { + rule.TagName = tagHelper.TagMatchingRules.Single().TagName; + rule.Attribute(attribute => + { + attribute.Name = "bind-" + valueAttribute.Name; + attribute.NameComparisonMode = RequiredAttributeDescriptor.NameComparisonMode.FullMatch; + }); + }); + + builder.BindAttribute(attribute => + { + attribute.Documentation = string.Format( + Resources.BindTagHelper_Component_Documentation, + valueAttribute.Name, + changeAttribute.Name); + + attribute.Name = "bind-" + valueAttribute.Name; + attribute.TypeName = valueAttribute.TypeName; + attribute.IsEnum = valueAttribute.IsEnum; + + // WTE has a bug 15.7p1 where a Tag Helper without a display-name that looks like + // a C# property will crash trying to create the toolips. + attribute.SetPropertyName(valueAttribute.GetPropertyName()); + }); + + results.Add(builder.Build()); + } + } + + return results; + } + + private struct ElementBindData + { + public ElementBindData( + string assembly, + string typeName, + string element, + string typeAttribute, + string suffix, + string valueAttribute, + string changeAttribute) + { + Assembly = assembly; + TypeName = typeName; + Element = element; + TypeAttribute = typeAttribute; + Suffix = suffix; + ValueAttribute = valueAttribute; + ChangeAttribute = changeAttribute; + } + + public string Assembly { get; } + public string TypeName { get; } + public string Element { get; } + public string TypeAttribute { get; } + public string Suffix { get; } + public string ValueAttribute { get; } + public string ChangeAttribute { get; } + } + + private class BindElementDataVisitor : SymbolVisitor + { + private List _results; + + public BindElementDataVisitor(List results) + { + _results = results; + } + + public override void VisitNamedType(INamedTypeSymbol symbol) + { + if (symbol.Name == "BindAttributes" && symbol.DeclaredAccessibility == Accessibility.Public) + { + _results.Add(symbol); + } + } + + public override void VisitNamespace(INamespaceSymbol symbol) + { + foreach (var member in symbol.GetMembers()) + { + Visit(member); + } + } + + public override void VisitAssembly(IAssemblySymbol symbol) + { + // This as a simple yet high-value optimization that excludes the vast majority of + // assemblies that (by definition) can't contain a component. + if (symbol.Name != null && !symbol.Name.StartsWith("System.", StringComparison.Ordinal)) + { + Visit(symbol.GlobalNamespace); + } + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorApi.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorApi.cs index a625f63fec..075e21f646 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorApi.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorApi.cs @@ -7,6 +7,8 @@ namespace Microsoft.AspNetCore.Blazor.Razor // Keep these in sync with the actual definitions internal static class BlazorApi { + public static readonly string AssemblyName = "Microsoft.AspNetCore.Blazor"; + public static class BlazorComponent { public static readonly string FullTypeName = "Microsoft.AspNetCore.Blazor.Components.BlazorComponent"; @@ -64,13 +66,27 @@ namespace Microsoft.AspNetCore.Blazor.Razor public static readonly string FullTypeName = "Microsoft.AspNetCore.Blazor.Components.RouteAttribute"; } + public static class BindElementAttribute + { + public static readonly string FullTypeName = "Microsoft.AspNetCore.Blazor.Components.BindElementAttribute"; + } + + public static class BindInputElementAttribute + { + public static readonly string FullTypeName = "Microsoft.AspNetCore.Blazor.Components.BindInputElementAttribute"; + } + public static class BindMethods { + public static readonly string FullTypeName = "Microsoft.AspNetCore.Blazor.Components.BindMethods"; + public static readonly string GetValue = "Microsoft.AspNetCore.Blazor.Components.BindMethods.GetValue"; public static readonly string SetValue = "Microsoft.AspNetCore.Blazor.Components.BindMethods.SetValue"; + + public static readonly string SetValueHandler = "Microsoft.AspNetCore.Blazor.Components.BindMethods.SetValueHandler"; } - + public static class UIEventHandler { public static readonly string FullTypeName = "Microsoft.AspNetCore.Blazor.UIEventHandler"; diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDiagnosticFactory.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDiagnosticFactory.cs index 78fcf47890..b8a2958029 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDiagnosticFactory.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDiagnosticFactory.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 System.Collections.Generic; using System.IO; using System.Linq; @@ -88,5 +89,21 @@ namespace Microsoft.AspNetCore.Blazor.Razor var diagnostic = RazorDiagnostic.Create(PageDirective_MustSpecifyRoute, source ?? SourceSpan.Undefined); return diagnostic; } + + public static readonly RazorDiagnosticDescriptor BindAttribute_Duplicates = + new RazorDiagnosticDescriptor( + "BL9989", + () => "The attribute '{0}' was matched by multiple bind attributes. Duplicates:{1}", + RazorDiagnosticSeverity.Error); + + public static RazorDiagnostic CreateBindAttribute_Duplicates(SourceSpan? source, string attribute, ComponentAttributeExtensionNode[] attributes) + { + var diagnostic = RazorDiagnostic.Create( + BindAttribute_Duplicates, + source ?? SourceSpan.Undefined, + attribute, + Environment.NewLine + string.Join(Environment.NewLine, attributes.Select(p => p.TagHelper.DisplayName))); + return diagnostic; + } } } diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorExtensionInitializer.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorExtensionInitializer.cs index 982d11121f..66c98560ff 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorExtensionInitializer.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorExtensionInitializer.cs @@ -65,12 +65,17 @@ namespace Microsoft.AspNetCore.Blazor.Razor builder.Features.Add(new ConfigureBlazorCodeGenerationOptions()); + // Implementation of components builder.Features.Add(new ComponentDocumentClassifierPass()); builder.Features.Add(new ComplexAttributeContentPass()); builder.Features.Add(new ComponentLoweringPass()); - builder.Features.Add(new ComponentTagHelperDescriptorProvider()); + // Implementation of bind + builder.Features.Add(new BindLoweringPass()); + builder.Features.Add(new BindTagHelperDescriptorProvider()); + builder.Features.Add(new OrphanTagHelperLoweringPass()); + if (builder.Configuration.ConfigurationName == DeclarationConfiguration.ConfigurationName) { // This is for 'declaration only' processing. We don't want to try and emit any method bodies during diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorMetadata.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorMetadata.cs new file mode 100644 index 0000000000..1e46548f5a --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorMetadata.cs @@ -0,0 +1,39 @@ +// 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. + +namespace Microsoft.AspNetCore.Blazor.Razor +{ + // Metadata used for Blazor's interations with the tag helper system + internal static class BlazorMetadata + { + // There's a bug in the 15.7 preview 1 Razor that prevents 'Kind' from being serialized + // this affects both tooling and build. For now our workaround is to ignore 'Kind' and + // use our own metadata entry to denote non-Component tag helpers. + public static readonly string SpecialKindKey = "Blazor.IsSpecialKind"; + + public static class Bind + { + public static readonly string RuntimeName = "Blazor.None"; + + public readonly static string TagHelperKind = "Blazor.Bind-0.1"; + + public readonly static string FallbackKey = "Blazor.Bind.Fallback"; + + public readonly static string TypeAttribute = "Blazor.Bind.TypeAttribute"; + + public readonly static string ValueAttribute = "Blazor.Bind.ValueAttribute"; + + public readonly static string ChangeAttribute = "Blazor.Bind.ChangeAttribute"; + } + + public static class Component + { + public static readonly string DelegateSignatureKey = "Blazor.DelegateSignature"; + + public static readonly string RuntimeName = "Blazor.IComponent"; + + public readonly static string TagHelperKind = "Blazor.Component-0.1"; + + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentAttributeExtensionNode.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentAttributeExtensionNode.cs index 425d5becf5..64797b5fca 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentAttributeExtensionNode.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentAttributeExtensionNode.cs @@ -61,6 +61,31 @@ namespace Microsoft.AspNetCore.Blazor.Razor } } + public ComponentAttributeExtensionNode(ComponentAttributeExtensionNode attributeNode) + { + if (attributeNode == null) + { + throw new ArgumentNullException(nameof(attributeNode)); + } + + AttributeName = attributeNode.AttributeName; + AttributeStructure = attributeNode.AttributeStructure; + BoundAttribute = attributeNode.BoundAttribute; + PropertyName = attributeNode.BoundAttribute.GetPropertyName(); + Source = attributeNode.Source; + TagHelper = attributeNode.TagHelper; + + for (var i = 0; i < attributeNode.Children.Count; i++) + { + Children.Add(attributeNode.Children[i]); + } + + for (var i = 0; i < attributeNode.Diagnostics.Count; i++) + { + Diagnostics.Add(attributeNode.Diagnostics[i]); + } + } + public override IntermediateNodeCollection Children { get; } = new IntermediateNodeCollection(); public string AttributeName { get; set; } diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentLoweringPass.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentLoweringPass.cs index 519c37a504..1134925548 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentLoweringPass.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentLoweringPass.cs @@ -1,16 +1,17 @@ // 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.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Razor.Language; -using Microsoft.AspNetCore.Razor.Language.Extensions; using Microsoft.AspNetCore.Razor.Language.Intermediate; namespace Microsoft.AspNetCore.Blazor.Razor { internal class ComponentLoweringPass : IntermediateNodePassBase, IRazorOptimizationPass { + // Run after our *special* tag helpers get lowered. + public override int Order => 1000; + protected override void ExecuteCore(RazorCodeDocument codeDocument, DocumentIntermediateNode documentNode) { var @namespace = documentNode.FindPrimaryNamespace(); @@ -26,20 +27,28 @@ namespace Microsoft.AspNetCore.Blazor.Razor var nodes = documentNode.FindDescendantNodes(); for (var i = 0; i < nodes.Count; i++) { + var count = 0; var node = nodes[i]; - if (node.TagHelpers.Count > 1) + for (var j = 0; j < node.TagHelpers.Count; j++) { - node.Diagnostics.Add(BlazorDiagnosticFactory.Create_MultipleComponents(node.Source, node.TagName, node.TagHelpers)); - } + if (node.TagHelpers[j].IsComponentTagHelper()) + { + // Only allow a single component tag helper per element. We also have some *special* tag helpers + // and they should have already been processed by now. + if (count++ > 1) + { + node.Diagnostics.Add(BlazorDiagnosticFactory.Create_MultipleComponents(node.Source, node.TagName, node.TagHelpers)); + break; + } - RewriteUsage(node, node.TagHelpers[0]); + RewriteUsage(node, node.TagHelpers[j]); + } + } } } private void RewriteUsage(TagHelperIntermediateNode node, TagHelperDescriptor tagHelper) { - // Ignore Kind here. Some versions of Razor have a bug in the serializer that ignores it. - // We need to surround the contents of the node with open and close nodes to ensure the component // is scoped correctly. node.Children.Insert(0, new ComponentOpenExtensionNode() diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentTagHelperDescriptorProvider.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentTagHelperDescriptorProvider.cs index 076f8762ce..9ba01f12e6 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentTagHelperDescriptorProvider.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentTagHelperDescriptorProvider.cs @@ -12,10 +12,6 @@ namespace Microsoft.AspNetCore.Blazor.Razor { internal class ComponentTagHelperDescriptorProvider : RazorEngineFeatureBase, ITagHelperDescriptorProvider { - public static readonly string DelegateSignatureMetadata = "Blazor.DelegateSignature"; - - public readonly static string ComponentTagHelperKind = ComponentDocumentClassifierPass.ComponentDocumentKind; - private static readonly SymbolDisplayFormat FullNameTypeDisplayFormat = SymbolDisplayFormat.FullyQualifiedFormat .WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted) @@ -78,12 +74,12 @@ namespace Microsoft.AspNetCore.Blazor.Razor var typeName = type.ToDisplayString(FullNameTypeDisplayFormat); var assemblyName = type.ContainingAssembly.Identity.Name; - var builder = TagHelperDescriptorBuilder.Create(ComponentTagHelperKind, typeName, assemblyName); + var builder = TagHelperDescriptorBuilder.Create(BlazorMetadata.Component.TagHelperKind, typeName, assemblyName); builder.SetTypeName(typeName); // This opts out this 'component' tag helper for any processing that's specific to the default // Razor ITagHelper runtime. - builder.Metadata[TagHelperMetadata.Runtime.Name] = "Blazor.IComponent"; + builder.Metadata[TagHelperMetadata.Runtime.Name] = BlazorMetadata.Component.RuntimeName; var xml = type.GetDocumentationCommentXml(); if (!string.IsNullOrEmpty(xml)) @@ -114,7 +110,7 @@ namespace Microsoft.AspNetCore.Blazor.Razor if (property.kind == PropertyKind.Delegate) { - pb.Metadata.Add(DelegateSignatureMetadata, bool.TrueString); + pb.Metadata.Add(BlazorMetadata.Component.DelegateSignatureKey, bool.TrueString); } xml = property.property.GetDocumentationCommentXml(); diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/OrphanTagHelperLoweringPass.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/OrphanTagHelperLoweringPass.cs new file mode 100644 index 0000000000..95ad5e222f --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/OrphanTagHelperLoweringPass.cs @@ -0,0 +1,283 @@ +// 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.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Intermediate; + +namespace Microsoft.AspNetCore.Blazor.Razor +{ + // We use some tag helpers that can be applied directly to HTML elements. When + // that happens, the default lowering pass will map the whole element as a tag helper. + // + // This phase exists to turn these 'orphan' tag helpers back into HTML elements so that + // go down the proper path for rendering. + internal class OrphanTagHelperLoweringPass : IntermediateNodePassBase, IRazorOptimizationPass + { + // Run after our other passes + public override int Order => 1000; + + protected override void ExecuteCore(RazorCodeDocument codeDocument, DocumentIntermediateNode documentNode) + { + if (codeDocument == null) + { + throw new ArgumentNullException(nameof(codeDocument)); + } + + if (documentNode == null) + { + throw new ArgumentNullException(nameof(documentNode)); + } + + var visitor = new Visitor(); + visitor.Visit(documentNode); + + for (var i = 0; i < visitor.References.Count; i++) + { + var reference = visitor.References[i]; + var tagHelperNode = (TagHelperIntermediateNode)reference.Node; + + // Since this is converted from a tag helper to a regular old HTMl element, we need to + // flatten out the structure + var insert = new List(); + insert.Add(new HtmlContentIntermediateNode() + { + Children = + { + new IntermediateToken() + { + Content = "<" + tagHelperNode.TagName + " ", + Kind = TokenKind.Html, + } + }, + }); + + for (var j = 0; j < tagHelperNode.Diagnostics.Count; j++) + { + insert[0].Diagnostics.Add(tagHelperNode.Diagnostics[j]); + } + + // We expect to see a body node, followed by a series of property/attribute nodes + // This isn't really the order we want, so skip over the body for now, and we'll do another + // pass that merges it in. + for (var j = 0; j < tagHelperNode.Children.Count; j++) + { + if (tagHelperNode.Children[j] is TagHelperBodyIntermediateNode) + { + continue; + } + else if (tagHelperNode.Children[j] is TagHelperHtmlAttributeIntermediateNode htmlAttribute) + { + if (htmlAttribute.Children.Count == 0) + { + RewriteEmptyAttributeContent(insert, htmlAttribute); + } + else if (htmlAttribute.Children[0] is HtmlContentIntermediateNode) + { + RewriteHtmlAttributeContent(insert, htmlAttribute); + } + else if (htmlAttribute.Children[0] is CSharpExpressionAttributeValueIntermediateNode csharpContent) + { + RewriteCSharpAttributeContent(insert, htmlAttribute); + } + } + else if (tagHelperNode.Children[j] is ComponentAttributeExtensionNode attributeNode) + { + RewriteComponentAttributeContent(insert, attributeNode); + } + else + { + // We shouldn't see anything else here, but just in case, add the content as-is. + insert.Add(tagHelperNode.Children[j]); + } + } + + if (tagHelperNode.TagMode == TagMode.SelfClosing) + { + insert.Add(new HtmlContentIntermediateNode() + { + Children = + { + new IntermediateToken() + { + Content = "/>", + Kind = TokenKind.Html, + } + } + }); + } + else if (tagHelperNode.TagMode == TagMode.StartTagOnly) + { + insert.Add(new HtmlContentIntermediateNode() + { + Children = + { + new IntermediateToken() + { + Content = ">", + Kind = TokenKind.Html, + } + } + }); + } + else + { + insert.Add(new HtmlContentIntermediateNode() + { + Children = + { + new IntermediateToken() + { + Content = ">", + Kind = TokenKind.Html, + } + } + }); + + for (var j = 0; j < tagHelperNode.Children.Count; j++) + { + if (tagHelperNode.Children[j] is TagHelperBodyIntermediateNode bodyNode) + { + insert.AddRange(bodyNode.Children); + } + } + + insert.Add(new HtmlContentIntermediateNode() + { + Children = + { + new IntermediateToken() + { + Content = "", + Kind = TokenKind.Html, + } + } + }); + } + + reference.InsertAfter(insert); + reference.Remove(); + } + } + private static void RewriteEmptyAttributeContent(List nodes, TagHelperHtmlAttributeIntermediateNode node) + { + nodes.Add(new HtmlContentIntermediateNode() + { + Children = + { + new IntermediateToken() + { + Content = node.AttributeName + " ", + Kind = TokenKind.Html, + } + } + }); + } + + private static void RewriteHtmlAttributeContent(List nodes, TagHelperHtmlAttributeIntermediateNode node) + { + switch (node.AttributeStructure) + { + case AttributeStructure.Minimized: + nodes.Add(new HtmlContentIntermediateNode() + { + Children = + { + new IntermediateToken() + { + Content = node.AttributeName + " ", + Kind = TokenKind.Html, + } + } + }); + break; + + // Blazor doesn't really care about preserving the fidelity of the attributes. + case AttributeStructure.NoQuotes: + case AttributeStructure.SingleQuotes: + case AttributeStructure.DoubleQuotes: + + var htmlNode = new HtmlContentIntermediateNode(); + nodes.Add(htmlNode); + + htmlNode.Children.Add(new IntermediateToken() + { + Content = node.AttributeName + "=\"", + Kind = TokenKind.Html, + }); + + for (var i = 0; i < node.Children[0].Children.Count; i++) + { + htmlNode.Children.Add(node.Children[0].Children[i]); + } + + htmlNode.Children.Add(new IntermediateToken() + { + Content = "\" ", + Kind = TokenKind.Html, + }); + + break; + } + } + + private static void RewriteCSharpAttributeContent(List nodes, TagHelperHtmlAttributeIntermediateNode node) + { + var attributeNode = new HtmlAttributeIntermediateNode() + { + AttributeName = node.AttributeName, + Prefix = "=\"", + Suffix = "\"", + }; + nodes.Add(attributeNode); + + var valueNode = new CSharpExpressionAttributeValueIntermediateNode(); + attributeNode.Children.Add(valueNode); + + for (var i = 0; i < node.Children[0].Children.Count; i++) + { + valueNode.Children.Add(node.Children[0].Children[i]); + } + } + + private void RewriteComponentAttributeContent(List nodes, ComponentAttributeExtensionNode node) + { + var attributeNode = new HtmlAttributeIntermediateNode() + { + AttributeName = node.AttributeName, + Prefix = "=\"", + Suffix = "\"", + }; + nodes.Add(attributeNode); + + var valueNode = new CSharpExpressionAttributeValueIntermediateNode(); + attributeNode.Children.Add(valueNode); + + for (var i = 0; i < node.Children[0].Children.Count; i++) + { + valueNode.Children.Add(node.Children[0].Children[i]); + } + } + + private class Visitor : IntermediateNodeWalker + { + public List References = new List(); + + public override void VisitTagHelper(TagHelperIntermediateNode node) + { + base.VisitTagHelper(node); + + // Use a post-order traversal because we're going to rewite tag helper nodes, and thus + // change the parent nodes. + // + // This ensures that we operate the leaf nodes first. + if (!node.TagHelpers.Any(t => t.IsComponentTagHelper())) + { + References.Add(new IntermediateNodeReference(Parent, node)); + } + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.Designer.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.Designer.cs index c96d67be19..8e9b245101 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.Designer.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.Designer.cs @@ -60,6 +60,51 @@ namespace Microsoft.AspNetCore.Blazor.Razor { } } + /// + /// Looks up a localized string similar to Binds the provided expression to the '{0}' property and a change event delegate to the '{1}' property of the component.. + /// + internal static string BindTagHelper_Component_Documentation { + get { + return ResourceManager.GetString("BindTagHelper_Component_Documentation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Binds the provided expression to the '{0}' attribute and a change event delegate to the '{1}' attribute.. + /// + internal static string BindTagHelper_Element_Documentation { + get { + return ResourceManager.GetString("BindTagHelper_Element_Documentation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Specifies a format to convert the value specified by the '{0}' attribute. The format string can currently only be used with expressions of type <code>DateTime</code>.. + /// + internal static string BindTagHelper_Element_Format_Documentation { + get { + return ResourceManager.GetString("BindTagHelper_Element_Format_Documentation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Binds the provided expression to an attribute and a change event, based on the naming of the bind attribute. For example: <code>bind-value-onchange="..."</code> will assign the current value of the expression to the 'value' attribute, and assign a delegate that attempts to set the value to the 'onchange' attribute.. + /// + internal static string BindTagHelper_Fallback_Documentation { + get { + return ResourceManager.GetString("BindTagHelper_Fallback_Documentation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Specifies a format to convert the value specified by the corresponding bind attribute. For example: <code>format-value="..."</code> will apply a format string to the value specified in <code>bind-value-...</code>. The format string can currently only be used with expressions of type <code>DateTime</code>.. + /// + internal static string BindTagHelper_Fallback_Format_Documentation { + get { + return ResourceManager.GetString("BindTagHelper_Fallback_Format_Documentation", resourceCulture); + } + } + /// /// Looks up a localized string similar to Declares an interface implementation for the current document.. /// diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.resx b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.resx index cd58654757..565fa192c9 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.resx +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Resources.resx @@ -117,6 +117,21 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Binds the provided expression to the '{0}' property and a change event delegate to the '{1}' property of the component. + + + Binds the provided expression to the '{0}' attribute and a change event delegate to the '{1}' attribute. + + + Specifies a format to convert the value specified by the '{0}' attribute. The format string can currently only be used with expressions of type <code>DateTime</code>. + + + Binds the provided expression to an attribute and a change event, based on the naming of the bind attribute. For example: <code>bind-value-onchange="..."</code> will assign the current value of the expression to the 'value' attribute, and assign a delegate that attempts to set the value to the 'onchange' attribute. + + + Specifies a format to convert the value specified by the corresponding bind attribute. For example: <code>format-value="..."</code> will apply a format string to the value specified in <code>bind-value-...</code>. The format string can currently only be used with expressions of type <code>DateTime</code>. + Declares an interface implementation for the current document. diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/TagHelperBoundAttributeDescriptorExtensions.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/TagHelperBoundAttributeDescriptorExtensions.cs index fe4dade43c..504eb0d04e 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/TagHelperBoundAttributeDescriptorExtensions.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/TagHelperBoundAttributeDescriptorExtensions.cs @@ -15,7 +15,7 @@ namespace Microsoft.AspNetCore.Blazor.Razor throw new ArgumentNullException(nameof(attribute)); } - var key = ComponentTagHelperDescriptorProvider.DelegateSignatureMetadata; + var key = BlazorMetadata.Component.DelegateSignatureKey; return attribute.Metadata.TryGetValue(key, out var value) && string.Equals(value, bool.TrueString); diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/TagHelperDescriptorExtensions.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/TagHelperDescriptorExtensions.cs new file mode 100644 index 0000000000..8b01ac6206 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/TagHelperDescriptorExtensions.cs @@ -0,0 +1,94 @@ +// 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 Microsoft.AspNetCore.Razor.Language; + +namespace Microsoft.AspNetCore.Blazor.Razor +{ + internal static class TagHelperDescriptorExtensions + { + public static bool IsBindTagHelper(this TagHelperDescriptor tagHelper) + { + if (tagHelper == null) + { + throw new ArgumentNullException(nameof(tagHelper)); + } + + return + tagHelper.Metadata.TryGetValue(BlazorMetadata.SpecialKindKey, out var kind) && + string.Equals(BlazorMetadata.Bind.TagHelperKind, kind); + } + + public static bool IsFallbackBindTagHelper(this TagHelperDescriptor tagHelper) + { + if (tagHelper == null) + { + throw new ArgumentNullException(nameof(tagHelper)); + } + + return + tagHelper.IsBindTagHelper() && + tagHelper.Metadata.TryGetValue(BlazorMetadata.Bind.FallbackKey, out var fallback) && + string.Equals(bool.TrueString, fallback); + } + + public static bool IsInputElementBindTagHelper(this TagHelperDescriptor tagHelper) + { + if (tagHelper == null) + { + throw new ArgumentNullException(nameof(tagHelper)); + } + + return + tagHelper.IsBindTagHelper() && + tagHelper.TagMatchingRules.Count == 1 && + string.Equals("input", tagHelper.TagMatchingRules[0].TagName); + } + + public static bool IsInputElementFallbackBindTagHelper(this TagHelperDescriptor tagHelper) + { + if (tagHelper == null) + { + throw new ArgumentNullException(nameof(tagHelper)); + } + + return + tagHelper.IsInputElementBindTagHelper() && + !tagHelper.Metadata.ContainsKey(BlazorMetadata.Bind.TypeAttribute); + } + + public static string GetValueAttributeName(this TagHelperDescriptor tagHelper) + { + if (tagHelper == null) + { + throw new ArgumentNullException(nameof(tagHelper)); + } + + tagHelper.Metadata.TryGetValue(BlazorMetadata.Bind.ValueAttribute, out var result); + return result; + } + + public static string GetChangeAttributeName(this TagHelperDescriptor tagHelper) + { + if (tagHelper == null) + { + throw new ArgumentNullException(nameof(tagHelper)); + } + + tagHelper.Metadata.TryGetValue(BlazorMetadata.Bind.ChangeAttribute, out var result); + return result; + } + + public static bool IsComponentTagHelper(this TagHelperDescriptor tagHelper) + { + if (tagHelper == null) + { + throw new ArgumentNullException(nameof(tagHelper)); + } + + return !tagHelper.Metadata.ContainsKey(BlazorMetadata.SpecialKindKey); + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor/Components/BindAttributes.cs b/src/Microsoft.AspNetCore.Blazor/Components/BindAttributes.cs new file mode 100644 index 0000000000..4c44cd18cc --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor/Components/BindAttributes.cs @@ -0,0 +1,26 @@ +// 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. + +namespace Microsoft.AspNetCore.Blazor.Components +{ + /// + /// Infrastructure for the discovery of bind attributes for markup elements. + /// + /// + /// To extend the set of bind attributes, define a public class named + /// BindAttributes and annotate it with the appropriate attributes. + /// + + // Handles cases like - this is a fallback and will be ignored + // when a specific type attribute is applied. + [BindInputElement(null, null, "value", "onchange")] + + // For right now, the BrowserRenderer translates the value attribute to the checked attribute. + [BindInputElement("checkbox", null, "value", "onchange")] + [BindInputElement("text", null, "value", "onchange")] + + [BindElement("select", null, "value", "onchange")] + public static class BindAttributes + { + } +} diff --git a/src/Microsoft.AspNetCore.Blazor/Components/BindElementAttribute.cs b/src/Microsoft.AspNetCore.Blazor/Components/BindElementAttribute.cs new file mode 100644 index 0000000000..1a1eb22fb1 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor/Components/BindElementAttribute.cs @@ -0,0 +1,42 @@ +// 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; + +namespace Microsoft.AspNetCore.Blazor.Components +{ + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] + public sealed class BindElementAttribute : Attribute + { + public BindElementAttribute(string element, string suffix, string valueAttribute, string changeAttribute) + { + if (element == null) + { + throw new ArgumentNullException(nameof(element)); + } + + if (valueAttribute == null) + { + throw new ArgumentNullException(nameof(valueAttribute)); + } + + if (changeAttribute == null) + { + throw new ArgumentNullException(nameof(changeAttribute)); + } + + Element = element; + ValueAttribute = valueAttribute; + ChangeAttribute = changeAttribute; + } + + public string Element { get; } + + // Set this to `value` for `bind-value` - set this to null for `bind` + public string Suffix { get; } + + public string ValueAttribute { get; } + + public string ChangeAttribute { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor/Components/BindInputElementAttribute.cs b/src/Microsoft.AspNetCore.Blazor/Components/BindInputElementAttribute.cs new file mode 100644 index 0000000000..01710d08db --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor/Components/BindInputElementAttribute.cs @@ -0,0 +1,37 @@ +// 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; + +namespace Microsoft.AspNetCore.Blazor.Components +{ + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] + public sealed class BindInputElementAttribute : Attribute + { + public BindInputElementAttribute(string type, string suffix, string valueAttribute, string changeAttribute) + { + if (valueAttribute == null) + { + throw new ArgumentNullException(nameof(valueAttribute)); + } + + if (changeAttribute == null) + { + throw new ArgumentNullException(nameof(changeAttribute)); + } + + Type = type; + Suffix = suffix; + ValueAttribute = valueAttribute; + ChangeAttribute = changeAttribute; + } + + public string Type { get; } + + public string Suffix { get; } + + public string ValueAttribute { get; } + + public string ChangeAttribute { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor/Components/BindMethods.cs b/src/Microsoft.AspNetCore.Blazor/Components/BindMethods.cs index e90d6e84fc..2565ec21f6 100644 --- a/src/Microsoft.AspNetCore.Blazor/Components/BindMethods.cs +++ b/src/Microsoft.AspNetCore.Blazor/Components/BindMethods.cs @@ -23,6 +23,64 @@ namespace Microsoft.AspNetCore.Blazor.Components value == default ? null : (format == null ? value.ToString() : value.ToString(format)); + /// + /// Not intended to be used directly. + /// + public static UIEventHandler SetValueHandler(Action setter, string existingValue) + { + return _ => setter((string)((UIChangeEventArgs)_).Value); + } + + /// + /// Not intended to be used directly. + /// + public static UIEventHandler SetValueHandler(Action setter, bool existingValue) + { + return _ => setter((bool)((UIChangeEventArgs)_).Value); + } + + /// + /// Not intended to be used directly. + /// + public static UIEventHandler SetValueHandler(Action setter, int existingValue) + { + return _ => setter(int.Parse((string)((UIChangeEventArgs)_).Value)); + } + + /// + /// Not intended to be used directly. + /// + public static UIEventHandler SetValueHandler(Action setter, DateTime existingValue) + { + return _ => SetDateTimeValue(setter, (object)((UIChangeEventArgs)_).Value, null); + } + + /// + /// Not intended to be used directly. + /// + public static UIEventHandler SetValueHandler(Action setter, DateTime existingValue, string format) + { + return _ => SetDateTimeValue(setter, (object)((UIChangeEventArgs)_).Value, format); + } + + /// + /// Not intended to be used directly. + /// + public static UIEventHandler SetValueHandler(Action setter, T existingValue) + { + if (!typeof(T).IsEnum) + { + throw new ArgumentException($"'bind' 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."); + } + + return _ => + { + var value = (string)((UIChangeEventArgs)_).Value; + var parsed = (T)Enum.Parse(typeof(T), value); + setter(parsed); + }; + } + /// /// Not intended to be used directly. /// @@ -35,6 +93,12 @@ namespace Microsoft.AspNetCore.Blazor.Components public static Action SetValue(Action setter, bool existingValue) => objValue => setter((bool)objValue); + /// + /// Not intended to be used directly. + /// + public static Action SetValue(Action setter, int existingValue) + => objValue => setter(int.Parse((string)objValue)); + /// /// Not intended to be used directly. /// diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/BindRazorIntegrationTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/BindRazorIntegrationTest.cs new file mode 100644 index 0000000000..71941dbd26 --- /dev/null +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/BindRazorIntegrationTest.cs @@ -0,0 +1,491 @@ +// 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 Microsoft.AspNetCore.Blazor.Test.Helpers; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace Microsoft.AspNetCore.Blazor.Build.Test +{ + public class BindRazorIntegrationTest : RazorIntegrationTestBase + { + internal override bool UseTwoPhaseCompilation => true; + + [Fact] + public void Render_BindToComponent_SpecifiesValue_WithMatchingProperties() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using System; +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : BlazorComponent + { + public int Value { get; set; } + + public Action ValueChanged { get; set; } + } +}")); + + var component = CompileToComponent(@" +@addTagHelper *, TestAssembly + +@functions { + public int ParentValue { get; set; } = 42; +}"); + + // Act + var frames = GetRenderTree(component); + + // Assert + Assert.Collection( + frames, + frame => AssertFrame.Component(frame, "Test.MyComponent", 3, 0), + frame => AssertFrame.Attribute(frame, "Value", 42, 1), + frame => AssertFrame.Attribute(frame, "ValueChanged", typeof(Action), 2), + frame => AssertFrame.Whitespace(frame, 3)); + } + + [Fact] + public void Render_BindToComponent_SpecifiesValue_WithoutMatchingProperties() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using System; +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : BlazorComponent, IComponent + { + void IComponent.SetParameters(ParameterCollection parameters) + { + } + } +}")); + + var component = CompileToComponent(@" +@addTagHelper *, TestAssembly + +@functions { + public int ParentValue { get; set; } = 42; +}"); + + // Act + var frames = GetRenderTree(component); + + // Assert + Assert.Collection( + frames, + frame => AssertFrame.Component(frame, "Test.MyComponent", 3, 0), + frame => AssertFrame.Attribute(frame, "Value", 42, 1), + frame => AssertFrame.Attribute(frame, "ValueChanged", typeof(UIEventHandler), 2), + frame => AssertFrame.Whitespace(frame, 3)); + } + + [Fact] + public void Render_BindToComponent_SpecifiesValueAndChangeEvent_WithMatchingProperties() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using System; +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : BlazorComponent + { + public int Value { get; set; } + + public Action OnChanged { get; set; } + } +}")); + + var component = CompileToComponent(@" +@addTagHelper *, TestAssembly + +@functions { + public int ParentValue { get; set; } = 42; +}"); + + // Act + var frames = GetRenderTree(component); + + // Assert + Assert.Collection( + frames, + frame => AssertFrame.Component(frame, "Test.MyComponent", 3, 0), + frame => AssertFrame.Attribute(frame, "Value", 42, 1), + frame => AssertFrame.Attribute(frame, "OnChanged", typeof(Action), 2), + frame => AssertFrame.Whitespace(frame, 3)); + } + + [Fact] + public void Render_BindToComponent_SpecifiesValueAndChangeEvent_WithoutMatchingProperties() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using System; +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : BlazorComponent, IComponent + { + void IComponent.SetParameters(ParameterCollection parameters) + { + } + } +}")); + + var component = CompileToComponent(@" +@addTagHelper *, TestAssembly + +@functions { + public int ParentValue { get; set; } = 42; +}"); + + // Act + var frames = GetRenderTree(component); + + // Assert + Assert.Collection( + frames, + frame => AssertFrame.Component(frame, "Test.MyComponent", 3, 0), + frame => AssertFrame.Attribute(frame, "Value", 42, 1), + frame => AssertFrame.Attribute(frame, "OnChanged", typeof(UIEventHandler), 2), + frame => AssertFrame.Whitespace(frame, 3)); + } + + [Fact] + public void Render_BindToElement_WritesAttributes() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using System; +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + [BindElement(""div"", null, ""myvalue"", ""myevent"")] + public static class BindAttributes + { + } +}")); + + var component = CompileToComponent(@" +@addTagHelper *, TestAssembly +
+@functions { + public string ParentValue { get; set; } = ""hi""; +}"); + + // Act + var frames = GetRenderTree(component); + + // Assert + Assert.Collection( + frames, + frame => AssertFrame.Element(frame, "div", 3, 0), + frame => AssertFrame.Attribute(frame, "myvalue", "hi", 1), + frame => AssertFrame.Attribute(frame, "myevent", typeof(UIEventHandler), 2), + frame => AssertFrame.Whitespace(frame, 3)); + } + + [Fact] + public void Render_BindToElementWithSuffix_WritesAttributes() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using System; +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + [BindElement(""div"", ""value"", ""myvalue"", ""myevent"")] + public static class BindAttributes + { + } +}")); + + var component = CompileToComponent(@" +@addTagHelper *, TestAssembly +
+@functions { + public string ParentValue { get; set; } = ""hi""; +}"); + + // Act + var frames = GetRenderTree(component); + + // Assert + Assert.Collection( + frames, + frame => AssertFrame.Element(frame, "div", 3, 0), + frame => AssertFrame.Attribute(frame, "myvalue", "hi", 1), + frame => AssertFrame.Attribute(frame, "myevent", typeof(UIEventHandler), 2), + frame => AssertFrame.Whitespace(frame, 3)); + } + + [Fact] + public void Render_BindDuplicates_ReportsDiagnostic() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using System; +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + [BindElement(""div"", ""value"", ""myvalue2"", ""myevent2"")] + [BindElement(""div"", ""value"", ""myvalue"", ""myevent"")] + public static class BindAttributes + { + } +}")); + + // Act + var result = CompileToCSharp(@" +@addTagHelper *, TestAssembly +
+@functions { + public string ParentValue { get; set; } = ""hi""; +}"); + + // Assert + var diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal("BL9989", diagnostic.Id); + Assert.Equal( + "The attribute 'bind-value' was matched by multiple bind attributes. Duplicates:" + Environment.NewLine + + "Test.BindAttributes" + Environment.NewLine + + "Test.BindAttributes", + diagnostic.GetMessage()); + } + + [Fact] + public void Render_BuiltIn_BindToInputWithoutType_WritesAttributes() + { + // Arrange + var component = CompileToComponent(@" +@addTagHelper *, TestAssembly + +@functions { + public int ParentValue { get; set; } = 42; +}"); + + // Act + var frames = GetRenderTree(component); + + // Assert + Assert.Collection( + frames, + frame => AssertFrame.Element(frame, "input", 3, 0), + frame => AssertFrame.Attribute(frame, "value", "42", 1), + frame => AssertFrame.Attribute(frame, "onchange", typeof(UIEventHandler), 2), + frame => AssertFrame.Whitespace(frame, 3)); + } + + [Fact] + public void Render_BuiltIn_BindToInputText_WithFormat_WritesAttributes() + { + // Arrange + var component = CompileToComponent(@" +@addTagHelper *, TestAssembly + +@functions { + public DateTime CurrentDate { get; set; } = new DateTime(2018, 1, 1); +}"); + + // Act + var frames = GetRenderTree(component); + + // Assert + Assert.Collection( + frames, + frame => AssertFrame.Element(frame, "input", 4, 0), + frame => AssertFrame.Attribute(frame, "type", "text", 1), + frame => AssertFrame.Attribute(frame, "value", "01/01/2018", 2), + frame => AssertFrame.Attribute(frame, "onchange", typeof(UIEventHandler), 3), + frame => AssertFrame.Whitespace(frame, 4)); + } + + [Fact] + public void Render_BuiltIn_BindToInputText_WithFormatFromProperty_WritesAttributes() + { + // Arrange + var component = CompileToComponent(@" +@addTagHelper *, TestAssembly + +@functions { + public DateTime CurrentDate { get; set; } = new DateTime(2018, 1, 1); + + public string Format { get; set; } = ""MM/dd/yyyy""; +}"); + + // Act + var frames = GetRenderTree(component); + + // Assert + Assert.Collection( + frames, + frame => AssertFrame.Element(frame, "input", 4, 0), + frame => AssertFrame.Attribute(frame, "type", "text", 1), + frame => AssertFrame.Attribute(frame, "value", "01/01/2018", 2), + frame => AssertFrame.Attribute(frame, "onchange", typeof(UIEventHandler), 3), + frame => AssertFrame.Whitespace(frame, 4)); + } + + [Fact] + public void Render_BuiltIn_BindToInputText_WritesAttributes() + { + // Arrange + var component = CompileToComponent(@" +@addTagHelper *, TestAssembly + +@functions { + public int ParentValue { get; set; } = 42; +}"); + + // Act + var frames = GetRenderTree(component); + + // Assert + Assert.Collection( + frames, + frame => AssertFrame.Element(frame, "input", 4, 0), + frame => AssertFrame.Attribute(frame, "type", "text", 1), + frame => AssertFrame.Attribute(frame, "value", "42", 2), + frame => AssertFrame.Attribute(frame, "onchange", typeof(UIEventHandler), 3), + frame => AssertFrame.Whitespace(frame, 4)); + } + + [Fact] + public void Render_BuiltIn_BindToInputCheckbox_WritesAttributes() + { + // Arrange + var component = CompileToComponent(@" +@addTagHelper *, TestAssembly + +@functions { + public bool Enabled { get; set; } +}"); + + // Act + var frames = GetRenderTree(component); + + // Assert + Assert.Collection( + frames, + frame => AssertFrame.Element(frame, "input", 4, 0), + frame => AssertFrame.Attribute(frame, "type", "checkbox", 1), + frame => AssertFrame.Attribute(frame, "value", "False", 2), + frame => AssertFrame.Attribute(frame, "onchange", typeof(UIEventHandler), 3), + frame => AssertFrame.Whitespace(frame, 4)); + } + + [Fact] + public void Render_BindToElementFallback_WritesAttributes() + { + // Arrange + var component = CompileToComponent(@" +@addTagHelper *, TestAssembly + +@functions { + public int ParentValue { get; set; } = 42; +}"); + + // Act + var frames = GetRenderTree(component); + + // Assert + Assert.Collection( + frames, + frame => AssertFrame.Element(frame, "input", 4, 0), + frame => AssertFrame.Attribute(frame, "type", "text", 1), + frame => AssertFrame.Attribute(frame, "value", "42", 2), + frame => AssertFrame.Attribute(frame, "onchange", typeof(UIEventHandler), 3), + frame => AssertFrame.Whitespace(frame, 4)); + } + + [Fact] + public void Render_BindToElementFallback_WithFormat_WritesAttributes() + { + // Arrange + var component = CompileToComponent(@" +@addTagHelper *, TestAssembly + +@functions { + public DateTime CurrentDate { get; set; } = new DateTime(2018, 1, 1); +}"); + + // Act + var frames = GetRenderTree(component); + + // Assert + Assert.Collection( + frames, + frame => AssertFrame.Element(frame, "input", 4, 0), + frame => AssertFrame.Attribute(frame, "type", "text", 1), + frame => AssertFrame.Attribute(frame, "value", "01/01", 2), + frame => AssertFrame.Attribute(frame, "onchange", typeof(UIEventHandler), 3), + frame => AssertFrame.Whitespace(frame, 4)); + } + + [Fact] // Additional coverage of OrphanTagHelperLoweringPass + public void Render_BindToElementFallback_SpecifiesValueAndChangeEvent_WithCSharpAttribute() + { + // Arrange + var component = CompileToComponent(@" +@addTagHelper *, TestAssembly + +@functions { + public int ParentValue { get; set; } = 42; +}"); + + // Act + var frames = GetRenderTree(component); + + // Assert + Assert.Collection( + frames, + frame => AssertFrame.Element(frame, "input", 5, 0), + frame => AssertFrame.Attribute(frame, "visible", 1), // This gets reordered in the node writer + frame => AssertFrame.Attribute(frame, "type", "text", 2), + frame => AssertFrame.Attribute(frame, "value", "42", 3), + frame => AssertFrame.Attribute(frame, "onchange", typeof(UIEventHandler), 4), + frame => AssertFrame.Whitespace(frame, 5)); + } + + [Fact] // Additional coverage of OrphanTagHelperLoweringPass + public void Render_BindToElementFallback_SpecifiesValueAndChangeEvent_BodyContent() + { + // Arrange + var component = CompileToComponent(@" +@addTagHelper *, TestAssembly +
+ @(42.ToString()) +
+@functions { + public int ParentValue { get; set; } = 42; +}"); + + // Act + var frames = GetRenderTree(component); + + // Assert + Assert.Collection( + frames, + frame => AssertFrame.Element(frame, "div", 7, 0), + frame => AssertFrame.Attribute(frame, "value", "42", 1), + frame => AssertFrame.Attribute(frame, "onchange", typeof(UIEventHandler), 2), + frame => AssertFrame.Whitespace(frame, 3), + frame => AssertFrame.Element(frame, "span", 2, 4), + frame => AssertFrame.Text(frame, "42", 5), + frame => AssertFrame.Whitespace(frame, 6), + frame => AssertFrame.Whitespace(frame, 7)); + } + } +} diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/ComponentRenderingRazorIntegrationTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/ComponentRenderingRazorIntegrationTest.cs index 22d44a2b1d..3f59e323f0 100644 --- a/test/Microsoft.AspNetCore.Blazor.Build.Test/ComponentRenderingRazorIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/ComponentRenderingRazorIntegrationTest.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 System.Linq; using Microsoft.AspNetCore.Blazor.RenderTree; using Microsoft.AspNetCore.Blazor.Test.Helpers; @@ -293,13 +294,11 @@ namespace Test AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" using Microsoft.AspNetCore.Blazor; using Microsoft.AspNetCore.Blazor.Components; - namespace Test { public class MyComponent : BlazorComponent { public string MyAttr { get; set; } - public RenderFragment ChildContent { get; set; } } } diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/RenderingRazorIntegrationTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/RenderingRazorIntegrationTest.cs index c0c6b1b263..03f30cae4b 100644 --- a/test/Microsoft.AspNetCore.Blazor.Build.Test/RenderingRazorIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/RenderingRazorIntegrationTest.cs @@ -364,7 +364,7 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test { // Arrange/Act var component = CompileToComponent( - @" + @" @functions { public string MyValue { get; set; } = ""Initial value""; }"); @@ -394,7 +394,7 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test { // Arrange/Act var component = CompileToComponent( - @" + @" @functions { public DateTime MyDate { get; set; } = new DateTime(2018, 3, 4, 1, 2, 3); }"); @@ -426,10 +426,10 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test // Arrange/Act var testDateFormat = "ddd yyyy-MM-dd"; var component = CompileToComponent( - @" - @functions { - public DateTime MyDate { get; set; } = new DateTime(2018, 3, 4); - }"); + $@" + @functions {{ + public DateTime MyDate {{ get; set; }} = new DateTime(2018, 3, 4); + }}"); var myDateProperty = component.GetType().GetProperty("MyDate"); // Assert @@ -456,7 +456,7 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test { // Arrange/Act var component = CompileToComponent( - @" + @" @functions { public bool MyValue { get; set; } = true; }"); @@ -487,7 +487,7 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test // Arrange/Act var myEnumType = FullTypeName(); var component = CompileToComponent( - $@" + $@" @functions {{ public {myEnumType} MyValue {{ get; set; }} = {myEnumType}.{nameof(MyEnum.FirstValue)}; }}"); diff --git a/test/Microsoft.AspNetCore.Blazor.Razor.Extensions.Test/BaseTagHelperDescriptorProviderTest.cs b/test/Microsoft.AspNetCore.Blazor.Razor.Extensions.Test/BaseTagHelperDescriptorProviderTest.cs new file mode 100644 index 0000000000..7304b05ddd --- /dev/null +++ b/test/Microsoft.AspNetCore.Blazor.Razor.Extensions.Test/BaseTagHelperDescriptorProviderTest.cs @@ -0,0 +1,44 @@ +// 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.Linq; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.Extensions.DependencyModel; + +namespace Microsoft.AspNetCore.Blazor.Razor.Extensions +{ + public abstract class BaseTagHelperDescriptorProviderTest + { + static BaseTagHelperDescriptorProviderTest() + { + var dependencyContext = DependencyContext.Load(typeof(ComponentTagHelperDescriptorProviderTest).Assembly); + + var metadataReferences = dependencyContext.CompileLibraries + .SelectMany(l => l.ResolveReferencePaths()) + .Select(assemblyPath => MetadataReference.CreateFromFile(assemblyPath)) + .ToArray(); + + BaseCompilation = CSharpCompilation.Create( + "TestAssembly", + Array.Empty(), + metadataReferences, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } + + protected static Compilation BaseCompilation { get; } + + // For simplicity in testing, exclude the built-in components. We'll add more and we + // don't want to update the tests when that happens. + protected static TagHelperDescriptor[] ExcludeBuiltInComponents(TagHelperDescriptorProviderContext context) + { + return context.Results + .Where(c => c.AssemblyName == "TestAssembly") + .OrderBy(c => c.Name) + .ToArray(); + } + + } +} diff --git a/test/Microsoft.AspNetCore.Blazor.Razor.Extensions.Test/BindTagHelperDescriptorProviderTest.cs b/test/Microsoft.AspNetCore.Blazor.Razor.Extensions.Test/BindTagHelperDescriptorProviderTest.cs new file mode 100644 index 0000000000..081c3506a2 --- /dev/null +++ b/test/Microsoft.AspNetCore.Blazor.Razor.Extensions.Test/BindTagHelperDescriptorProviderTest.cs @@ -0,0 +1,682 @@ +// 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.Linq; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Razor; +using Xunit; + +namespace Microsoft.AspNetCore.Blazor.Razor.Extensions +{ + public class BindTagHelperDescriptorProviderTest : BaseTagHelperDescriptorProviderTest + { + [Fact] + public void Excecute_FindsBindTagHelperOnComponentType_CreatesDescriptor() + { + // Arrange + var compilation = BaseCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(@" +using System; +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : IComponent + { + public void Init(RenderHandle renderHandle) { } + + public void SetParameters(ParameterCollection parameters) { } + + public string MyProperty { get; set; } + + public Action MyPropertyChanged { get; set; } + } +} +")); + + Assert.Empty(compilation.GetDiagnostics()); + + var context = TagHelperDescriptorProviderContext.Create(); + context.SetCompilation(compilation); + + // We run after component discovery and depend on the results. + var componentProvider = new ComponentTagHelperDescriptorProvider(); + componentProvider.Execute(context); + + var provider = new BindTagHelperDescriptorProvider(); + + // Act + provider.Execute(context); + + // Assert + var matches = GetBindTagHelpers(context); + var bind = Assert.Single(matches); + + // These are features Bind Tags Helpers don't use. Verifying them once here and + // then ignoring them. + Assert.Empty(bind.AllowedChildTags); + Assert.Null(bind.TagOutputHint); + + // These are features that are invariants of all Bind Tag Helpers. Verifying them once + // here and then ignoring them. + Assert.Empty(bind.Diagnostics); + Assert.False(bind.HasErrors); + Assert.Equal(BlazorMetadata.Bind.TagHelperKind, bind.Kind); + Assert.Equal(BlazorMetadata.Bind.RuntimeName, bind.Metadata[TagHelperMetadata.Runtime.Name]); + Assert.False(bind.IsDefaultKind()); + Assert.False(bind.KindUsesDefaultTagHelperRuntime()); + + Assert.Equal("MyProperty", bind.Metadata[BlazorMetadata.Bind.ValueAttribute]); + Assert.Equal("MyPropertyChanged", bind.Metadata[BlazorMetadata.Bind.ChangeAttribute]); + + Assert.Equal( + "Binds the provided expression to the 'MyProperty' property and a change event " + + "delegate to the 'MyPropertyChanged' property of the component.", + bind.Documentation); + + // These are all trivally derived from the assembly/namespace/type name + Assert.Equal("TestAssembly", bind.AssemblyName); + Assert.Equal("Test.MyComponent", bind.Name); + Assert.Equal("Test.MyComponent", bind.DisplayName); + Assert.Equal("Test.MyComponent", bind.GetTypeName()); + + var rule = Assert.Single(bind.TagMatchingRules); + Assert.Empty(rule.Diagnostics); + Assert.False(rule.HasErrors); + Assert.Null(rule.ParentTag); + Assert.Equal("MyComponent", rule.TagName); + Assert.Equal(TagStructure.Unspecified, rule.TagStructure); + + var requiredAttribute = Assert.Single(rule.Attributes); + Assert.Empty(requiredAttribute.Diagnostics); + Assert.Equal("bind-MyProperty", requiredAttribute.DisplayName); + Assert.Equal("bind-MyProperty", requiredAttribute.Name); + Assert.Equal(RequiredAttributeDescriptor.NameComparisonMode.FullMatch, requiredAttribute.NameComparison); + Assert.Null(requiredAttribute.Value); + Assert.Equal(RequiredAttributeDescriptor.ValueComparisonMode.None, requiredAttribute.ValueComparison); + + var attribute = Assert.Single(bind.BoundAttributes); + + // Invariants + Assert.Empty(attribute.Diagnostics); + Assert.False(attribute.HasErrors); + Assert.Equal(BlazorMetadata.Bind.TagHelperKind, attribute.Kind); + Assert.False(attribute.IsDefaultKind()); + Assert.False(attribute.HasIndexer); + Assert.Null(attribute.IndexerNamePrefix); + Assert.Null(attribute.IndexerTypeName); + Assert.False(attribute.IsIndexerBooleanProperty); + Assert.False(attribute.IsIndexerStringProperty); + + Assert.Equal( + "Binds the provided expression to the 'MyProperty' property and a change event " + + "delegate to the 'MyPropertyChanged' property of the component.", + attribute.Documentation); + + Assert.Equal("bind-MyProperty", attribute.Name); + Assert.Equal("MyProperty", attribute.GetPropertyName()); + Assert.Equal("string Test.MyComponent.MyProperty", attribute.DisplayName); + + // Defined from the property type + Assert.Equal("System.String", attribute.TypeName); + Assert.True(attribute.IsStringProperty); + Assert.False(attribute.IsBooleanProperty); + Assert.False(attribute.IsEnum); + } + + [Fact] + public void Excecute_NoMatchedPropertiesOnComponent_IgnoresComponent() + { + // Arrange + var compilation = BaseCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(@" +using System; +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : IComponent + { + public void Init(RenderHandle renderHandle) { } + + public void SetParameters(ParameterCollection parameters) { } + + public string MyProperty { get; set; } + + public Action MyPropertyChangedNotMatch { get; set; } + } +} +")); + + Assert.Empty(compilation.GetDiagnostics()); + + var context = TagHelperDescriptorProviderContext.Create(); + context.SetCompilation(compilation); + + // We run after component discovery and depend on the results. + var componentProvider = new ComponentTagHelperDescriptorProvider(); + componentProvider.Execute(context); + + var provider = new BindTagHelperDescriptorProvider(); + + // Act + provider.Execute(context); + + // Assert + var matches = GetBindTagHelpers(context); + Assert.Empty(matches); + } + + [Fact] + public void Excecute_BindOnElement_CreatesDescriptor() + { + // Arrange + var compilation = BaseCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(@" +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + [BindElement(""div"", null, ""myprop"", ""myevent"")] + public class BindAttributes + { + } +} +")); + + Assert.Empty(compilation.GetDiagnostics()); + + var context = TagHelperDescriptorProviderContext.Create(); + context.SetCompilation(compilation); + + var provider = new BindTagHelperDescriptorProvider(); + + // Act + provider.Execute(context); + + // Assert + var matches = GetBindTagHelpers(context); + var bind = Assert.Single(matches); + + // These are features Bind Tags Helpers don't use. Verifying them once here and + // then ignoring them. + Assert.Empty(bind.AllowedChildTags); + Assert.Null(bind.TagOutputHint); + + // These are features that are invariants of all Bind Tag Helpers. Verifying them once + // here and then ignoring them. + Assert.Empty(bind.Diagnostics); + Assert.False(bind.HasErrors); + Assert.Equal(BlazorMetadata.Bind.TagHelperKind, bind.Kind); + Assert.Equal(BlazorMetadata.Bind.RuntimeName, bind.Metadata[TagHelperMetadata.Runtime.Name]); + Assert.False(bind.IsDefaultKind()); + Assert.False(bind.KindUsesDefaultTagHelperRuntime()); + + Assert.Equal("myprop", bind.Metadata[BlazorMetadata.Bind.ValueAttribute]); + Assert.Equal("myevent", bind.Metadata[BlazorMetadata.Bind.ChangeAttribute]); + Assert.False(bind.IsInputElementBindTagHelper()); + Assert.False(bind.IsInputElementFallbackBindTagHelper()); + + Assert.Equal( + "Binds the provided expression to the 'myprop' attribute and a change event " + + "delegate to the 'myevent' attribute.", + bind.Documentation); + + // These are all trivally derived from the assembly/namespace/type name + Assert.Equal("TestAssembly", bind.AssemblyName); + Assert.Equal("Bind", bind.Name); + Assert.Equal("Test.BindAttributes", bind.DisplayName); + Assert.Equal("Test.BindAttributes", bind.GetTypeName()); + + // The tag matching rule for a bind-Component is always the component name + the attribute name + var rule = Assert.Single(bind.TagMatchingRules); + Assert.Empty(rule.Diagnostics); + Assert.False(rule.HasErrors); + Assert.Null(rule.ParentTag); + Assert.Equal("div", rule.TagName); + Assert.Equal(TagStructure.Unspecified, rule.TagStructure); + + var requiredAttribute = Assert.Single(rule.Attributes); + Assert.Empty(requiredAttribute.Diagnostics); + Assert.Equal("bind", requiredAttribute.DisplayName); + Assert.Equal("bind", requiredAttribute.Name); + Assert.Equal(RequiredAttributeDescriptor.NameComparisonMode.FullMatch, requiredAttribute.NameComparison); + Assert.Null(requiredAttribute.Value); + Assert.Equal(RequiredAttributeDescriptor.ValueComparisonMode.None, requiredAttribute.ValueComparison); + + var attribute = Assert.Single(bind.BoundAttributes, a => a.Name.StartsWith("bind")); + + // Invariants + Assert.Empty(attribute.Diagnostics); + Assert.False(attribute.HasErrors); + Assert.Equal(BlazorMetadata.Bind.TagHelperKind, attribute.Kind); + Assert.False(attribute.IsDefaultKind()); + Assert.False(attribute.HasIndexer); + Assert.Null(attribute.IndexerNamePrefix); + Assert.Null(attribute.IndexerTypeName); + Assert.False(attribute.IsIndexerBooleanProperty); + Assert.False(attribute.IsIndexerStringProperty); + + Assert.Equal( + "Binds the provided expression to the 'myprop' attribute and a change event " + + "delegate to the 'myevent' attribute.", + attribute.Documentation); + + Assert.Equal("bind", attribute.Name); + Assert.Equal("Bind", attribute.GetPropertyName()); + Assert.Equal("object Test.BindAttributes.Bind", attribute.DisplayName); + + // Defined from the property type + Assert.Equal("System.Object", attribute.TypeName); + Assert.False(attribute.IsStringProperty); + Assert.False(attribute.IsBooleanProperty); + Assert.False(attribute.IsEnum); + + attribute = Assert.Single(bind.BoundAttributes, a => a.Name.StartsWith("format")); + + // Invariants + Assert.Empty(attribute.Diagnostics); + Assert.False(attribute.HasErrors); + Assert.Equal(BlazorMetadata.Bind.TagHelperKind, attribute.Kind); + Assert.False(attribute.IsDefaultKind()); + Assert.False(attribute.HasIndexer); + Assert.Null(attribute.IndexerNamePrefix); + Assert.Null(attribute.IndexerTypeName); + Assert.False(attribute.IsIndexerBooleanProperty); + Assert.False(attribute.IsIndexerStringProperty); + + Assert.Equal( + "Specifies a format to convert the value specified by the 'bind' attribute. " + + "The format string can currently only be used with expressions of type DateTime.", + attribute.Documentation); + + Assert.Equal("format-myprop", attribute.Name); + Assert.Equal("Format_myprop", attribute.GetPropertyName()); + Assert.Equal("string Test.BindAttributes.Format_myprop", attribute.DisplayName); + + // Defined from the property type + Assert.Equal("System.String", attribute.TypeName); + Assert.True(attribute.IsStringProperty); + Assert.False(attribute.IsBooleanProperty); + Assert.False(attribute.IsEnum); + } + + [Fact] + public void Execute_BindOnElementWithSuffix_CreatesDescriptor() + { + // Arrange + var compilation = BaseCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(@" +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + [BindElement(""div"", ""myprop"", ""myprop"", ""myevent"")] + public class BindAttributes + { + } +} +")); + + Assert.Empty(compilation.GetDiagnostics()); + + var context = TagHelperDescriptorProviderContext.Create(); + context.SetCompilation(compilation); + + var provider = new BindTagHelperDescriptorProvider(); + + // Act + provider.Execute(context); + + // Assert + var matches = GetBindTagHelpers(context); + var bind = Assert.Single(matches); + + Assert.Equal("myprop", bind.Metadata[BlazorMetadata.Bind.ValueAttribute]); + Assert.Equal("myevent", bind.Metadata[BlazorMetadata.Bind.ChangeAttribute]); + Assert.False(bind.IsInputElementBindTagHelper()); + Assert.False(bind.IsInputElementFallbackBindTagHelper()); + + var rule = Assert.Single(bind.TagMatchingRules); + Assert.Equal("div", rule.TagName); + Assert.Equal(TagStructure.Unspecified, rule.TagStructure); + + var requiredAttribute = Assert.Single(rule.Attributes); + Assert.Equal("bind-myprop", requiredAttribute.DisplayName); + Assert.Equal("bind-myprop", requiredAttribute.Name); + + var attribute = Assert.Single(bind.BoundAttributes, a => a.Name.StartsWith("bind")); + Assert.Equal("bind-myprop", attribute.Name); + Assert.Equal("Bind_myprop", attribute.GetPropertyName()); + Assert.Equal("object Test.BindAttributes.Bind_myprop", attribute.DisplayName); + + attribute = Assert.Single(bind.BoundAttributes, a => a.Name.StartsWith("format")); + Assert.Equal("format-myprop", attribute.Name); + Assert.Equal("Format_myprop", attribute.GetPropertyName()); + Assert.Equal("string Test.BindAttributes.Format_myprop", attribute.DisplayName); + } + + [Fact] + public void Execute_BindOnInputElementWithoutTypeAttribute_CreatesDescriptor() + { + // Arrange + var compilation = BaseCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(@" +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + [BindInputElement(null, null, ""myprop"", ""myevent"")] + public class BindAttributes + { + } +} +")); + + Assert.Empty(compilation.GetDiagnostics()); + + var context = TagHelperDescriptorProviderContext.Create(); + context.SetCompilation(compilation); + + var provider = new BindTagHelperDescriptorProvider(); + + // Act + provider.Execute(context); + + // Assert + var matches = GetBindTagHelpers(context); + var bind = Assert.Single(matches); + + Assert.Equal("myprop", bind.Metadata[BlazorMetadata.Bind.ValueAttribute]); + Assert.Equal("myevent", bind.Metadata[BlazorMetadata.Bind.ChangeAttribute]); + Assert.False(bind.Metadata.ContainsKey(BlazorMetadata.Bind.TypeAttribute)); + Assert.True(bind.IsInputElementBindTagHelper()); + Assert.True(bind.IsInputElementFallbackBindTagHelper()); + + var rule = Assert.Single(bind.TagMatchingRules); + Assert.Equal("input", rule.TagName); + Assert.Equal(TagStructure.Unspecified, rule.TagStructure); + + var requiredAttribute = Assert.Single(rule.Attributes); + Assert.Equal("bind", requiredAttribute.DisplayName); + Assert.Equal("bind", requiredAttribute.Name); + + var attribute = Assert.Single(bind.BoundAttributes, a => a.Name.StartsWith("bind")); + Assert.Equal("bind", attribute.Name); + Assert.Equal("Bind", attribute.GetPropertyName()); + Assert.Equal("object Test.BindAttributes.Bind", attribute.DisplayName); + + attribute = Assert.Single(bind.BoundAttributes, a => a.Name.StartsWith("format")); + Assert.Equal("format-myprop", attribute.Name); + Assert.Equal("Format_myprop", attribute.GetPropertyName()); + Assert.Equal("string Test.BindAttributes.Format_myprop", attribute.DisplayName); + } + + [Fact] + public void Execute_BindOnInputElementWithTypeAttribute_CreatesDescriptor() + { + // Arrange + var compilation = BaseCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(@" +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + [BindInputElement(""checkbox"", null, ""myprop"", ""myevent"")] + public class BindAttributes + { + } +} +")); + + Assert.Empty(compilation.GetDiagnostics()); + + var context = TagHelperDescriptorProviderContext.Create(); + context.SetCompilation(compilation); + + var provider = new BindTagHelperDescriptorProvider(); + + // Act + provider.Execute(context); + + // Assert + var matches = GetBindTagHelpers(context); + var bind = Assert.Single(matches); + + Assert.Equal("myprop", bind.Metadata[BlazorMetadata.Bind.ValueAttribute]); + Assert.Equal("myevent", bind.Metadata[BlazorMetadata.Bind.ChangeAttribute]); + Assert.Equal("checkbox", bind.Metadata[BlazorMetadata.Bind.TypeAttribute]); + Assert.True(bind.IsInputElementBindTagHelper()); + Assert.False(bind.IsInputElementFallbackBindTagHelper()); + + var rule = Assert.Single(bind.TagMatchingRules); + Assert.Equal("input", rule.TagName); + Assert.Equal(TagStructure.Unspecified, rule.TagStructure); + + Assert.Collection( + rule.Attributes, + a => + { + Assert.Equal("type", a.DisplayName); + Assert.Equal("type", a.Name); + Assert.Equal(RequiredAttributeDescriptor.NameComparisonMode.FullMatch, a.NameComparison); + Assert.Equal("checkbox", a.Value); + Assert.Equal(RequiredAttributeDescriptor.ValueComparisonMode.FullMatch, a.ValueComparison); + }, + a => + { + Assert.Equal("bind", a.DisplayName); + Assert.Equal("bind", a.Name); + }); + + var attribute = Assert.Single(bind.BoundAttributes, a => a.Name.StartsWith("bind")); + Assert.Equal("bind", attribute.Name); + Assert.Equal("Bind", attribute.GetPropertyName()); + Assert.Equal("object Test.BindAttributes.Bind", attribute.DisplayName); + + attribute = Assert.Single(bind.BoundAttributes, a => a.Name.StartsWith("format")); + Assert.Equal("format-myprop", attribute.Name); + Assert.Equal("Format_myprop", attribute.GetPropertyName()); + Assert.Equal("string Test.BindAttributes.Format_myprop", attribute.DisplayName); + } + + [Fact] + public void Execute_BindOnInputElementWithTypeAttributeAndSuffix_CreatesDescriptor() + { + // Arrange + var compilation = BaseCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(@" +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + [BindInputElement(""checkbox"", ""somevalue"", ""myprop"", ""myevent"")] + public class BindAttributes + { + } +} +")); + + Assert.Empty(compilation.GetDiagnostics()); + + var context = TagHelperDescriptorProviderContext.Create(); + context.SetCompilation(compilation); + + var provider = new BindTagHelperDescriptorProvider(); + + // Act + provider.Execute(context); + + // Assert + var matches = GetBindTagHelpers(context); + var bind = Assert.Single(matches); + + Assert.Equal("myprop", bind.Metadata[BlazorMetadata.Bind.ValueAttribute]); + Assert.Equal("myevent", bind.Metadata[BlazorMetadata.Bind.ChangeAttribute]); + Assert.Equal("checkbox", bind.Metadata[BlazorMetadata.Bind.TypeAttribute]); + Assert.True(bind.IsInputElementBindTagHelper()); + Assert.False(bind.IsInputElementFallbackBindTagHelper()); + + var rule = Assert.Single(bind.TagMatchingRules); + Assert.Equal("input", rule.TagName); + Assert.Equal(TagStructure.Unspecified, rule.TagStructure); + + Assert.Collection( + rule.Attributes, + a => + { + Assert.Equal("type", a.DisplayName); + Assert.Equal("type", a.Name); + Assert.Equal(RequiredAttributeDescriptor.NameComparisonMode.FullMatch, a.NameComparison); + Assert.Equal("checkbox", a.Value); + Assert.Equal(RequiredAttributeDescriptor.ValueComparisonMode.FullMatch, a.ValueComparison); + }, + a => + { + Assert.Equal("bind-somevalue", a.DisplayName); + Assert.Equal("bind-somevalue", a.Name); + }); + + var attribute = Assert.Single(bind.BoundAttributes, a => a.Name.StartsWith("bind")); + Assert.Equal("bind-somevalue", attribute.Name); + Assert.Equal("Bind_somevalue", attribute.GetPropertyName()); + Assert.Equal("object Test.BindAttributes.Bind_somevalue", attribute.DisplayName); + + attribute = Assert.Single(bind.BoundAttributes, a => a.Name.StartsWith("format")); + Assert.Equal("format-somevalue", attribute.Name); + Assert.Equal("Format_somevalue", attribute.GetPropertyName()); + Assert.Equal("string Test.BindAttributes.Format_somevalue", attribute.DisplayName); + } + + [Fact] + public void Excecute_BindFallback_CreatesDescriptor() + { + // Arrange + var compilation = BaseCompilation; + Assert.Empty(compilation.GetDiagnostics()); + + var context = TagHelperDescriptorProviderContext.Create(); + context.SetCompilation(compilation); + + var provider = new BindTagHelperDescriptorProvider(); + + // Act + provider.Execute(context); + + // Assert + var bind = Assert.Single(context.Results, r => r.IsFallbackBindTagHelper()); + + // These are features Bind Tags Helpers don't use. Verifying them once here and + // then ignoring them. + Assert.Empty(bind.AllowedChildTags); + Assert.Null(bind.TagOutputHint); + + // These are features that are invariants of all Bind Tag Helpers. Verifying them once + // here and then ignoring them. + Assert.Empty(bind.Diagnostics); + Assert.False(bind.HasErrors); + Assert.Equal(BlazorMetadata.Bind.TagHelperKind, bind.Kind); + Assert.Equal(BlazorMetadata.Bind.RuntimeName, bind.Metadata[TagHelperMetadata.Runtime.Name]); + Assert.False(bind.IsDefaultKind()); + Assert.False(bind.KindUsesDefaultTagHelperRuntime()); + + Assert.False(bind.Metadata.ContainsKey(BlazorMetadata.Bind.ValueAttribute)); + Assert.False(bind.Metadata.ContainsKey(BlazorMetadata.Bind.ChangeAttribute)); + Assert.True(bind.IsFallbackBindTagHelper()); + + Assert.Equal( + "Binds the provided expression to an attribute and a change event, based on the naming of " + + "the bind attribute. For example: bind-value-onchange=\"...\" will assign the " + + "current value of the expression to the 'value' attribute, and assign a delegate that attempts " + + "to set the value to the 'onchange' attribute.", + bind.Documentation); + + // These are all trivally derived from the assembly/namespace/type name + Assert.Equal("Microsoft.AspNetCore.Blazor", bind.AssemblyName); + Assert.Equal("Bind", bind.Name); + Assert.Equal("Microsoft.AspNetCore.Blazor.Components.Bind", bind.DisplayName); + Assert.Equal("Microsoft.AspNetCore.Blazor.Components.Bind", bind.GetTypeName()); + + // The tag matching rule for a bind-Component is always the component name + the attribute name + var rule = Assert.Single(bind.TagMatchingRules); + Assert.Empty(rule.Diagnostics); + Assert.False(rule.HasErrors); + Assert.Null(rule.ParentTag); + Assert.Equal("*", rule.TagName); + Assert.Equal(TagStructure.Unspecified, rule.TagStructure); + + var requiredAttribute = Assert.Single(rule.Attributes); + Assert.Empty(requiredAttribute.Diagnostics); + Assert.Equal("bind-...", requiredAttribute.DisplayName); + Assert.Equal("bind-", requiredAttribute.Name); + Assert.Equal(RequiredAttributeDescriptor.NameComparisonMode.PrefixMatch, requiredAttribute.NameComparison); + Assert.Null(requiredAttribute.Value); + Assert.Equal(RequiredAttributeDescriptor.ValueComparisonMode.None, requiredAttribute.ValueComparison); + + var attribute = Assert.Single(bind.BoundAttributes, a => a.Name.StartsWith("bind")); + + // Invariants + Assert.Empty(attribute.Diagnostics); + Assert.False(attribute.HasErrors); + Assert.Equal(BlazorMetadata.Bind.TagHelperKind, attribute.Kind); + Assert.False(attribute.IsDefaultKind()); + Assert.False(attribute.IsIndexerBooleanProperty); + Assert.False(attribute.IsIndexerStringProperty); + + Assert.True(attribute.HasIndexer); + Assert.Equal("bind-", attribute.IndexerNamePrefix); + Assert.Equal("System.Object", attribute.IndexerTypeName); + + Assert.Equal( + "Binds the provided expression to an attribute and a change event, based on the naming of " + + "the bind attribute. For example: bind-value-onchange=\"...\" will assign the " + + "current value of the expression to the 'value' attribute, and assign a delegate that attempts " + + "to set the value to the 'onchange' attribute.", + attribute.Documentation); + + Assert.Equal("bind-...", attribute.Name); + Assert.Equal("Bind", attribute.GetPropertyName()); + Assert.Equal( + "System.Collections.Generic.Dictionary Microsoft.AspNetCore.Blazor.Components.Bind.Bind", + attribute.DisplayName); + + // Defined from the property type + Assert.Equal("System.Collections.Generic.Dictionary", attribute.TypeName); + Assert.False(attribute.IsStringProperty); + Assert.False(attribute.IsBooleanProperty); + Assert.False(attribute.IsEnum); + + attribute = Assert.Single(bind.BoundAttributes, a => a.Name.StartsWith("format")); + + // Invariants + Assert.Empty(attribute.Diagnostics); + Assert.False(attribute.HasErrors); + Assert.Equal(BlazorMetadata.Bind.TagHelperKind, attribute.Kind); + Assert.False(attribute.IsDefaultKind()); + Assert.True(attribute.HasIndexer); + Assert.Equal("format-", attribute.IndexerNamePrefix); + Assert.Equal("System.String", attribute.IndexerTypeName); + Assert.False(attribute.IsIndexerBooleanProperty); + Assert.True(attribute.IsIndexerStringProperty); + + Assert.Equal( + "Specifies a format to convert the value specified by the corresponding bind attribute. " + + "For example: format-value=\"...\" will apply a format string to the value " + + "specified in bind-value-.... The format string can currently only be used with " + + "expressions of type DateTime.", + attribute.Documentation); + + Assert.Equal("format-...", attribute.Name); + Assert.Equal("Format", attribute.GetPropertyName()); + Assert.Equal( + "System.Collections.Generic.Dictionary Microsoft.AspNetCore.Blazor.Components.Bind.Format", + attribute.DisplayName); + + // Defined from the property type + Assert.Equal("System.Collections.Generic.Dictionary", attribute.TypeName); + Assert.False(attribute.IsStringProperty); + Assert.False(attribute.IsBooleanProperty); + Assert.False(attribute.IsEnum); + } + + + private static TagHelperDescriptor[] GetBindTagHelpers(TagHelperDescriptorProviderContext context) + { + return ExcludeBuiltInComponents(context).Where(t => t.IsBindTagHelper()).ToArray(); + } + } +} diff --git a/test/Microsoft.AspNetCore.Blazor.Razor.Extensions.Test/ComponentTagHelperDescriptorProviderTest.cs b/test/Microsoft.AspNetCore.Blazor.Razor.Extensions.Test/ComponentTagHelperDescriptorProviderTest.cs index d10c331c53..a415a2de4a 100644 --- a/test/Microsoft.AspNetCore.Blazor.Razor.Extensions.Test/ComponentTagHelperDescriptorProviderTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Razor.Extensions.Test/ComponentTagHelperDescriptorProviderTest.cs @@ -1,37 +1,16 @@ // 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.Linq; using Microsoft.AspNetCore.Razor.Language; -using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Razor; -using Microsoft.Extensions.DependencyModel; using Xunit; namespace Microsoft.AspNetCore.Blazor.Razor.Extensions { - public class ComponentTagHelperDescriptorProviderTest + public class ComponentTagHelperDescriptorProviderTest : BaseTagHelperDescriptorProviderTest { - static ComponentTagHelperDescriptorProviderTest() - { - var dependencyContext = DependencyContext.Load(typeof(ComponentTagHelperDescriptorProviderTest).Assembly); - - var metadataReferences = dependencyContext.CompileLibraries - .SelectMany(l => l.ResolveReferencePaths()) - .Select(assemblyPath => MetadataReference.CreateFromFile(assemblyPath)) - .ToArray(); - - BaseCompilation = CSharpCompilation.Create( - "TestAssembly", - Array.Empty(), - metadataReferences, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - } - - private static Compilation BaseCompilation { get; } - [Fact] public void Excecute_FindsIComponentType_CreatesDescriptor() { @@ -77,8 +56,7 @@ namespace Test // here and then ignoring them. Assert.Empty(component.Diagnostics); Assert.False(component.HasErrors); - Assert.Equal(ComponentTagHelperDescriptorProvider.ComponentTagHelperKind, component.Kind); - Assert.Equal("Blazor.Component-0.1", component.Kind); + Assert.Equal(BlazorMetadata.Component.TagHelperKind, component.Kind); Assert.False(component.IsDefaultKind()); Assert.False(component.KindUsesDefaultTagHelperRuntime()); @@ -328,15 +306,5 @@ namespace Test Assert.False(attribute.IsStringProperty); Assert.True(attribute.IsDelegateProperty()); } - - // For simplicity in testing, exclude the built-in components. We'll add more and we - // don't want to update the tests when that happens. - private TagHelperDescriptor[] ExcludeBuiltInComponents(TagHelperDescriptorProviderContext context) - { - return context.Results - .Where(c => c.AssemblyName == "TestAssembly") - .OrderBy(c => c.Name) - .ToArray(); - } } } diff --git a/test/shared/AssertFrame.cs b/test/shared/AssertFrame.cs index 1b6bf118c1..a61323c975 100644 --- a/test/shared/AssertFrame.cs +++ b/test/shared/AssertFrame.cs @@ -59,6 +59,12 @@ namespace Microsoft.AspNetCore.Blazor.Test.Helpers Assert.Equal(attributeValue, frame.AttributeValue); } + public static void Attribute(RenderTreeFrame frame, string attributeName, Type valueType, int? sequence = null) + { + AssertFrame.Attribute(frame, attributeName, sequence); + Assert.IsType(valueType, frame.AttributeValue); + } + public static void Attribute(RenderTreeFrame frame, string attributeName, Action attributeValidator, int? sequence = null) { AssertFrame.Attribute(frame, attributeName, sequence); diff --git a/test/testapps/BasicTestApp/BindCasesComponent.cshtml b/test/testapps/BasicTestApp/BindCasesComponent.cshtml index 2a202900cb..fbb6d3b739 100644 --- a/test/testapps/BasicTestApp/BindCasesComponent.cshtml +++ b/test/testapps/BasicTestApp/BindCasesComponent.cshtml @@ -1,30 +1,30 @@ 

Textbox

Initially blank: - + @textboxInitiallyBlankValue

Initially populated: - + @textboxInitiallyPopulatedValue

Checkbox

Initially unchecked: - + @checkboxInitiallyUncheckedValue

Initially checked: - + @checkboxInitiallyCheckedValue

Select

-