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

-