Add AnchorTagHelper.

- Added a TagHelper that targets the <a> tag and allows users to do the equivalent of Html.ActionLink or Html.RouteLink.
- Added tests to validate AnchorTagHelper functionality.

#1247
This commit is contained in:
NTaylorMullen 2014-10-10 23:12:02 -07:00 committed by Doug Bunting
parent 4c98c8fcb9
commit 57d1c542db
4 changed files with 429 additions and 0 deletions

View File

@ -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
{
/// <summary>
/// <see cref="ITagHelper"/> implementation targeting &lt;a&gt; elements.
/// </summary>
[TagName("a")]
public class AnchorTagHelper : TagHelper
{
private const string RouteAttributePrefix = "route-";
private const string Href = "href";
[Activate]
private IHtmlGenerator Generator { get; set; }
/// <summary>
/// The name of the action method.
/// </summary>
/// <remarks>Must be <c>null</c> if <see cref="Route"/> is non-<c>null</c>.</remarks>
public string Action { get; set; }
/// <summary>
/// The name of the controller.
/// </summary>
/// <remarks>Must be <c>null</c> if <see cref="Route"/> is non-<c>null</c>.</remarks>
public string Controller { get; set; }
/// <summary>
/// The protocol for the URL, such as &quot;http&quot; or &quot;https&quot;.
/// </summary>
public string Protocol { get; set; }
/// <summary>
/// The host name.
/// </summary>
public string Host { get; set; }
/// <summary>
/// The URL fragment name.
/// </summary>
public string Fragment { get; set; }
/// <summary>
/// Name of the route.
/// </summary>
/// <remarks>
/// Must be <c>null</c> if <see cref="Action"/> or <see cref="Controller"/> is non-<c>null</c>.
/// </remarks>
public string Route { get; set; }
/// <inheritdoc />
/// <remarks>Does nothing if user provides an "href" attribute. Throws an
/// <see cref="InvalidOperationException"/> if "href" attribute is provided and <see cref="Action"/>,
/// <see cref="Controller"/>, or <see cref="Route"/> are non-<c>null</c>.</remarks>
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(
"<a>",
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(
"<a>",
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<string, object> GetRouteValues(
TagHelperOutput output, IEnumerable<KeyValuePair<string, string>> routePrefixedAttributes)
{
Dictionary<string, object> 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<string, string> for route values.
routeValues = routePrefixedAttributes.ToDictionary(
attribute => attribute.Key.Substring(RouteAttributePrefix.Length),
attribute => (object)attribute.Value);
}
return routeValues;
}
}
}

View File

@ -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);
/// <summary>
/// Cannot determine an {4} for {0}. An {0} with a specified {1} must not have an {2} or {3} attribute.
/// </summary>
internal static string AnchorTagHelper_CannotDetermineHrefRouteActionOrControllerSpecified
{
get { return GetString("AnchorTagHelper_CannotDetermineHrefRouteActionOrControllerSpecified"); }
}
/// <summary>
/// Cannot determine an {4} for {0}. An {0} with a specified {1} must not have an {2} or {3} attribute.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
internal static string AnchorTagHelper_CannotOverrideSpecifiedHref
{
get { return GetString("AnchorTagHelper_CannotOverrideSpecifiedHref"); }
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Cannot determine an {1} for {0}. A {0} with a URL-based {1} must not have attributes starting with {3} or a {2} attribute.
/// </summary>

View File

@ -117,6 +117,12 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="AnchorTagHelper_CannotDetermineHrefRouteActionOrControllerSpecified" xml:space="preserve">
<value>Cannot determine an {4} for {0}. An {0} with a specified {1} must not have an {2} or {3} attribute.</value>
</data>
<data name="AnchorTagHelper_CannotOverrideSpecifiedHref" xml:space="preserve">
<value>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.</value>
</data>
<data name="FormTagHelper_CannotDetermineAction" xml:space="preserve">
<value>Cannot determine an {1} for {0}. A {0} with a URL-based {1} must not have attributes starting with {3} or a {2} attribute.</value>
</data>

View File

@ -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<string, object>
{
{ "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<string, string>
{
{ "id", "myanchor" },
{ "route-foo", "bar" },
},
content: "Something");
var urlHelper = new Mock<IUrlHelper>();
urlHelper
.Setup(mock => mock.Action(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<object>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>()))
.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<string, object>());
var output = new TagHelperOutput(
"a",
attributes: new Dictionary<string, string>(),
content: string.Empty);
var generator = new Mock<IHtmlGenerator>(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<string, object>());
var output = new TagHelperOutput(
"a",
attributes: new Dictionary<string, string>(),
content: string.Empty);
var generator = new Mock<IHtmlGenerator>();
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<string, string>()
{
{ "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 <a>. An <a> 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<InvalidOperationException>(
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<string, string>(),
content: string.Empty);
var expectedErrorMessage = "Cannot determine an href for <a>. An <a> with a " +
"specified route must not have an action or controller attribute.";
// Act & Assert
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
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);
}
}
}