diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/AnchorTagHelper.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/AnchorTagHelper.cs new file mode 100644 index 0000000000..55cbd258ce --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/AnchorTagHelper.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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.AspNet.Mvc.Rendering; +using Microsoft.AspNet.Razor.Runtime.TagHelpers; + +namespace Microsoft.AspNet.Mvc.TagHelpers +{ + /// + /// implementation targeting <a> elements. + /// + [TagName("a")] + public class AnchorTagHelper : TagHelper + { + private const string RouteAttributePrefix = "route-"; + private const string Href = "href"; + + [Activate] + private IHtmlGenerator Generator { get; set; } + + /// + /// The name of the action method. + /// + /// Must be null if is non-null. + public string Action { get; set; } + + /// + /// The name of the controller. + /// + /// Must be null if is non-null. + public string Controller { get; set; } + + /// + /// The protocol for the URL, such as "http" or "https". + /// + public string Protocol { get; set; } + + /// + /// The host name. + /// + public string Host { get; set; } + + /// + /// The URL fragment name. + /// + public string Fragment { get; set; } + + /// + /// Name of the route. + /// + /// + /// Must be null if or is non-null. + /// + public string Route { get; set; } + + /// + /// Does nothing if user provides an "href" attribute. Throws an + /// if "href" attribute is provided and , + /// , or are non-null. + public override void Process(TagHelperContext context, TagHelperOutput output) + { + var routePrefixedAttributes = output.FindPrefixedAttributes(RouteAttributePrefix); + + // If there's an "href" on the tag it means it's being used as a normal anchor. + if (output.Attributes.ContainsKey(Href)) + { + if (Action != null || + Controller != null || + Route != null || + Protocol != null || + Host != null || + Fragment != null || + routePrefixedAttributes.Any()) + { + // User specified an href and one of the bound attributes; can't determine the href attribute. + throw new InvalidOperationException( + Resources.FormatAnchorTagHelper_CannotOverrideSpecifiedHref( + "", + nameof(Action).ToLowerInvariant(), + nameof(Controller).ToLowerInvariant(), + nameof(Route).ToLowerInvariant(), + nameof(Protocol).ToLowerInvariant(), + nameof(Host).ToLowerInvariant(), + nameof(Fragment).ToLowerInvariant(), + RouteAttributePrefix, + Href)); + } + } + else + { + TagBuilder tagBuilder; + var routeValues = GetRouteValues(output, routePrefixedAttributes); + + if (Route == null) + { + tagBuilder = Generator.GenerateActionLink(linkText: string.Empty, + actionName: Action, + controllerName: Controller, + protocol: Protocol, + hostname: Host, + fragment: Fragment, + routeValues: routeValues, + htmlAttributes: null); + } + else if (Action != null || Controller != null) + { + // Route and Action or Controller were specified. Can't determine the href attribute. + throw new InvalidOperationException( + Resources.FormatAnchorTagHelper_CannotDetermineHrefRouteActionOrControllerSpecified( + "", + nameof(Route).ToLowerInvariant(), + nameof(Action).ToLowerInvariant(), + nameof(Controller).ToLowerInvariant(), + Href)); + } + else + { + tagBuilder = Generator.GenerateRouteLink(linkText: string.Empty, + routeName: Route, + protocol: Protocol, + hostName: Host, + fragment: Fragment, + routeValues: routeValues, + htmlAttributes: null); + } + + if (tagBuilder != null) + { + output.MergeAttributes(tagBuilder); + } + } + } + + // TODO: We will not need this method once https://github.com/aspnet/Razor/issues/89 is completed. + private static Dictionary GetRouteValues( + TagHelperOutput output, IEnumerable> routePrefixedAttributes) + { + Dictionary routeValues = null; + if (routePrefixedAttributes.Any()) + { + // Prefixed values should be treated as bound attributes, remove them from the output. + output.RemoveRange(routePrefixedAttributes); + + // Generator.GenerateForm does not accept a Dictionary for route values. + routeValues = routePrefixedAttributes.ToDictionary( + attribute => attribute.Key.Substring(RouteAttributePrefix.Length), + attribute => (object)attribute.Value); + } + + return routeValues; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/Properties/Resources.Designer.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/Properties/Resources.Designer.cs index 3f07276690..f326434f03 100644 --- a/src/Microsoft.AspNet.Mvc.TagHelpers/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/Properties/Resources.Designer.cs @@ -10,6 +10,38 @@ namespace Microsoft.AspNet.Mvc.TagHelpers private static readonly ResourceManager _resourceManager = new ResourceManager("Microsoft.AspNet.Mvc.TagHelpers.Resources", typeof(Resources).GetTypeInfo().Assembly); + /// + /// Cannot determine an {4} for {0}. An {0} with a specified {1} must not have an {2} or {3} attribute. + /// + internal static string AnchorTagHelper_CannotDetermineHrefRouteActionOrControllerSpecified + { + get { return GetString("AnchorTagHelper_CannotDetermineHrefRouteActionOrControllerSpecified"); } + } + + /// + /// Cannot determine an {4} for {0}. An {0} with a specified {1} must not have an {2} or {3} attribute. + /// + internal static string FormatAnchorTagHelper_CannotDetermineHrefRouteActionOrControllerSpecified(object p0, object p1, object p2, object p3, object p4) + { + return string.Format(CultureInfo.CurrentCulture, GetString("AnchorTagHelper_CannotDetermineHrefRouteActionOrControllerSpecified"), p0, p1, p2, p3, p4); + } + + /// + /// Cannot determine an {8} for {0}. An {0} with a specified {8} must not have attributes starting with {7} or an {1}, {2}, {3}, {4}, {5} or {6} attribute. + /// + internal static string AnchorTagHelper_CannotOverrideSpecifiedHref + { + get { return GetString("AnchorTagHelper_CannotOverrideSpecifiedHref"); } + } + + /// + /// Cannot determine an {8} for {0}. An {0} with a specified {8} must not have attributes starting with {7} or an {1}, {2}, {3}, {4}, {5} or {6} attribute. + /// + internal static string FormatAnchorTagHelper_CannotOverrideSpecifiedHref(object p0, object p1, object p2, object p3, object p4, object p5, object p6, object p7, object p8) + { + return string.Format(CultureInfo.CurrentCulture, GetString("AnchorTagHelper_CannotOverrideSpecifiedHref"), p0, p1, p2, p3, p4, p5, p6, p7, p8); + } + /// /// Cannot determine an {1} for {0}. A {0} with a URL-based {1} must not have attributes starting with {3} or a {2} attribute. /// diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/Resources.resx b/src/Microsoft.AspNet.Mvc.TagHelpers/Resources.resx index 722a84ffe9..db5e382720 100644 --- a/src/Microsoft.AspNet.Mvc.TagHelpers/Resources.resx +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/Resources.resx @@ -117,6 +117,12 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Cannot determine an {4} for {0}. An {0} with a specified {1} must not have an {2} or {3} attribute. + + + Cannot determine an {8} for {0}. An {0} with a specified {8} must not have attributes starting with {7} or an {1}, {2}, {3}, {4}, {5} or {6} attribute. + Cannot determine an {1} for {0}. A {0} with a URL-based {1} must not have attributes starting with {3} or a {2} attribute. diff --git a/test/Microsoft.AspNet.Mvc.TagHelpers.Test/AnchorTagHelperTest.cs b/test/Microsoft.AspNet.Mvc.TagHelpers.Test/AnchorTagHelperTest.cs new file mode 100644 index 0000000000..2e0fbc0367 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.TagHelpers.Test/AnchorTagHelperTest.cs @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNet.Mvc.ModelBinding; +using Microsoft.AspNet.Mvc.Razor; +using Microsoft.AspNet.Mvc.Rendering; +using Microsoft.AspNet.Razor.Runtime.TagHelpers; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc.TagHelpers +{ + public class AnchorTagHelperTest + { + [Fact] + public async Task ProcessAsync_GeneratesExpectedOutput() + { + // Arrange + var metadataProvider = new DataAnnotationsModelMetadataProvider(); + var anchorTagHelper = new AnchorTagHelper + { + Action = "index", + Controller = "home", + Fragment = "hello=world", + Host = "contoso.com", + Protocol = "http" + }; + + var tagHelperContext = new TagHelperContext( + allAttributes: new Dictionary + { + { "id", "myanchor" }, + { "route-foo", "bar" }, + { "action", "index" }, + { "controller", "home" }, + { "fragment", "hello=world" }, + { "host", "contoso.com" }, + { "protocol", "http" } + }); + var output = new TagHelperOutput( + "a", + attributes: new Dictionary + { + { "id", "myanchor" }, + { "route-foo", "bar" }, + }, + content: "Something"); + + var urlHelper = new Mock(); + urlHelper + .Setup(mock => mock.Action( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns("home/index"); + + var htmlGenerator = new TestableHtmlGenerator(metadataProvider, urlHelper.Object); + var viewContext = TestableHtmlGenerator.GetViewContext(model: null, + htmlGenerator: htmlGenerator, + metadataProvider: metadataProvider); + + var activator = new DefaultTagHelperActivator(); + activator.Activate(anchorTagHelper, viewContext); + + // Act + await anchorTagHelper.ProcessAsync(tagHelperContext, output); + + // Assert + Assert.Equal(2, output.Attributes.Count); + var attribute = Assert.Single(output.Attributes, kvp => kvp.Key.Equals("id")); + Assert.Equal("myanchor", attribute.Value); + attribute = Assert.Single(output.Attributes, kvp => kvp.Key.Equals("href")); + Assert.Equal("home/index", attribute.Value); + Assert.Equal("Something", output.Content); + Assert.Equal("a", output.TagName); + } + + [Fact] + public async Task ProcessAsync_CallsIntoRouteLinkWithExpectedParameters() + { + // Arrange + var anchorTagHelper = new AnchorTagHelper + { + Route = "Default", + Protocol = "http", + Host = "contoso.com", + Fragment = "hello=world" + }; + var context = new TagHelperContext( + allAttributes: new Dictionary()); + var output = new TagHelperOutput( + "a", + attributes: new Dictionary(), + content: string.Empty); + + var generator = new Mock(MockBehavior.Strict); + generator + .Setup(mock => mock.GenerateRouteLink( + string.Empty, "Default", "http", "contoso.com", "hello=world", null, null)) + .Returns(new TagBuilder("a")) + .Verifiable(); + + SetGenerator(anchorTagHelper, generator.Object); + + // Act & Assert + await anchorTagHelper.ProcessAsync(context, output); + generator.Verify(); + Assert.Equal("a", output.TagName); + Assert.Empty(output.Attributes); + Assert.Empty(output.Content); + } + + [Fact] + public async Task ProcessAsync_CallsIntoActionLinkWithExpectedParameters() + { + // Arrange + var anchorTagHelper = new AnchorTagHelper + { + Action = "Index", + Controller = "Home", + Protocol = "http", + Host = "contoso.com", + Fragment = "hello=world" + }; + var context = new TagHelperContext( + allAttributes: new Dictionary()); + var output = new TagHelperOutput( + "a", + attributes: new Dictionary(), + content: string.Empty); + + var generator = new Mock(); + generator + .Setup(mock => mock.GenerateActionLink( + string.Empty, "Index", "Home", "http", "contoso.com", "hello=world", null, null)) + .Returns(new TagBuilder("a")) + .Verifiable(); + + SetGenerator(anchorTagHelper, generator.Object); + + // Act & Assert + await anchorTagHelper.ProcessAsync(context, output); + generator.Verify(); + Assert.Equal("a", output.TagName); + Assert.Empty(output.Attributes); + Assert.Empty(output.Content); + } + + [Theory] + [InlineData("Action")] + [InlineData("Controller")] + [InlineData("Route")] + [InlineData("Protocol")] + [InlineData("Host")] + [InlineData("Fragment")] + [InlineData("route-")] + public async Task ProcessAsync_ThrowsIfHrefConflictsWithBoundAttributes(string propertyName) + { + // Arrange + var anchorTagHelper = new AnchorTagHelper(); + var output = new TagHelperOutput( + "a", + attributes: new Dictionary() + { + { "href", "http://www.contoso.com" } + }, + content: string.Empty); + + if (propertyName == "route-") + { + output.Attributes.Add("route-foo", "bar"); + } + else + { + typeof(AnchorTagHelper).GetProperty(propertyName).SetValue(anchorTagHelper, "Home"); + } + + var expectedErrorMessage = "Cannot determine an href for . An with a specified href must not " + + "have attributes starting with route- or an action, controller, route, " + + "protocol, host or fragment attribute."; + + // Act & Assert + var ex = await Assert.ThrowsAsync( + async () => + { + await anchorTagHelper.ProcessAsync(context: null, output: output); + }); + + Assert.Equal(expectedErrorMessage, ex.Message); + } + + [Theory] + [InlineData("Action")] + [InlineData("Controller")] + public async Task ProcessAsync_ThrowsIfRouteAndActionOrControllerProvided(string propertyName) + { + // Arrange + var anchorTagHelper = new AnchorTagHelper + { + Route = "Default" + }; + typeof(AnchorTagHelper).GetProperty(propertyName).SetValue(anchorTagHelper, "Home"); + var output = new TagHelperOutput( + "a", + attributes: new Dictionary(), + content: string.Empty); + var expectedErrorMessage = "Cannot determine an href for . An with a " + + "specified route must not have an action or controller attribute."; + + // Act & Assert + var ex = await Assert.ThrowsAsync( + async () => + { + await anchorTagHelper.ProcessAsync(context: null, output: output); + }); + + Assert.Equal(expectedErrorMessage, ex.Message); + } + + private void SetGenerator(ITagHelper tagHelper, IHtmlGenerator generator) + { + var tagHelperType = tagHelper.GetType(); + + tagHelperType.GetProperty("Generator", BindingFlags.NonPublic | BindingFlags.Instance) + .SetValue(tagHelper, generator); + } + } +} \ No newline at end of file