diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/AnchorTagHelper.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/AnchorTagHelper.cs index 612e76e1b6..1dd7a2583d 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/AnchorTagHelper.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/AnchorTagHelper.cs @@ -14,6 +14,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers /// [HtmlTargetElement("a", Attributes = ActionAttributeName)] [HtmlTargetElement("a", Attributes = ControllerAttributeName)] + [HtmlTargetElement("a", Attributes = AreaAttributeName)] [HtmlTargetElement("a", Attributes = FragmentAttributeName)] [HtmlTargetElement("a", Attributes = HostAttributeName)] [HtmlTargetElement("a", Attributes = ProtocolAttributeName)] @@ -24,6 +25,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { private const string ActionAttributeName = "asp-action"; private const string ControllerAttributeName = "asp-controller"; + private const string AreaAttributeName = "asp-area"; private const string FragmentAttributeName = "asp-fragment"; private const string HostAttributeName = "asp-host"; private const string ProtocolAttributeName = "asp-protocol"; @@ -67,6 +69,13 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers [HtmlAttributeName(ControllerAttributeName)] public string Controller { get; set; } + /// + /// The name of the area. + /// + /// Must be null if is non-null. + [HtmlAttributeName(AreaAttributeName)] + public string Area { get; set; } + /// /// The protocol for the URL, such as "http" or "https". /// @@ -147,6 +156,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { if (Action != null || Controller != null || + Area != null || Route != null || Protocol != null || Host != null || @@ -159,6 +169,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers "", ActionAttributeName, ControllerAttributeName, + AreaAttributeName, RouteAttributeName, ProtocolAttributeName, HostAttributeName, @@ -180,6 +191,17 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers } } + if (Area != null) + { + if (routeValues == null) + { + routeValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + // Unconditionally replace any value from asp-route-area. + routeValues["area"] = Area; + } + TagBuilder tagBuilder; if (Route == null) { diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/FormTagHelper.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/FormTagHelper.cs index 922f5ebaa8..029f6d72e1 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/FormTagHelper.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/FormTagHelper.cs @@ -15,6 +15,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers /// [HtmlTargetElement("form", Attributes = ActionAttributeName)] [HtmlTargetElement("form", Attributes = AntiforgeryAttributeName)] + [HtmlTargetElement("form", Attributes = AreaAttributeName)] [HtmlTargetElement("form", Attributes = ControllerAttributeName)] [HtmlTargetElement("form", Attributes = RouteAttributeName)] [HtmlTargetElement("form", Attributes = RouteValuesDictionaryName)] @@ -23,6 +24,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { private const string ActionAttributeName = "asp-action"; private const string AntiforgeryAttributeName = "asp-antiforgery"; + private const string AreaAttributeName = "asp-area"; private const string ControllerAttributeName = "asp-controller"; private const string RouteAttributeName = "asp-route"; private const string RouteValuesDictionaryName = "asp-all-route-data"; @@ -66,6 +68,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers [HtmlAttributeName(ControllerAttributeName)] public string Controller { get; set; } + /// + /// The name of the area. + /// + [HtmlAttributeName(AreaAttributeName)] + public string Area { get; set; } + /// /// Whether the antiforgery token should be generated. /// @@ -141,7 +149,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers // If "action" is already set, it means the user is attempting to use a normal
. if (output.Attributes.ContainsName(HtmlActionAttributeName)) { - if (Action != null || Controller != null || Route != null || RouteValues.Count != 0) + if (Action != null || Controller != null || Area != null || Route != null || RouteValues.Count != 0) { // User also specified bound attributes we cannot use. throw new InvalidOperationException( @@ -150,6 +158,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers HtmlActionAttributeName, ActionAttributeName, ControllerAttributeName, + AreaAttributeName, RouteAttributeName, RouteValuesPrefix)); } @@ -171,6 +180,17 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers } } + if (Area != null) + { + if (routeValues == null) + { + routeValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + // Unconditionally replace any value from asp-route-area. + routeValues["area"] = Area; + } + TagBuilder tagBuilder; if (Route == null) { diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs index 8811b52047..b55a638681 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs @@ -27,7 +27,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers } /// - /// Cannot override the '{8}' attribute 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 override the '{9}' attribute for {0}. An {0} with a specified '{9}' must not have attributes starting with '{8}' or an '{1}', '{2}', '{3}', '{4}', '{5}', '{6}', or '{7}' attribute. /// internal static string AnchorTagHelper_CannotOverrideHref { @@ -35,15 +35,15 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers } /// - /// Cannot override the '{8}' attribute 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 override the '{9}' attribute for {0}. An {0} with a specified '{9}' must not have attributes starting with '{8}' or an '{1}', '{2}', '{3}', '{4}', '{5}', '{6}', or '{7}' attribute. /// - internal static string FormatAnchorTagHelper_CannotOverrideHref(object p0, object p1, object p2, object p3, object p4, object p5, object p6, object p7, object p8) + internal static string FormatAnchorTagHelper_CannotOverrideHref(object p0, object p1, object p2, object p3, object p4, object p5, object p6, object p7, object p8, object p9) { - return string.Format(CultureInfo.CurrentCulture, GetString("AnchorTagHelper_CannotOverrideHref"), p0, p1, p2, p3, p4, p5, p6, p7, p8); + return string.Format(CultureInfo.CurrentCulture, GetString("AnchorTagHelper_CannotOverrideHref"), p0, p1, p2, p3, p4, p5, p6, p7, p8, p9); } /// - /// Cannot override the '{1}' attribute for {0}. A {0} with a specified '{1}' must not have attributes starting with '{5}' or an '{2}' or '{3}' or '{4}' attribute. + /// Cannot override the '{1}' attribute for {0}. A {0} with a specified '{1}' must not have attributes starting with '{6}' or an '{2}' or '{3}' or '{4}' or '{5}' attribute. /// internal static string FormTagHelper_CannotOverrideAction { @@ -51,11 +51,11 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers } /// - /// Cannot override the '{1}' attribute for {0}. A {0} with a specified '{1}' must not have attributes starting with '{5}' or an '{2}' or '{3}' or '{4}' attribute. + /// Cannot override the '{1}' attribute for {0}. A {0} with a specified '{1}' must not have attributes starting with '{6}' or an '{2}' or '{3}' or '{4}' or '{5}' attribute. /// - internal static string FormatFormTagHelper_CannotOverrideAction(object p0, object p1, object p2, object p3, object p4, object p5) + internal static string FormatFormTagHelper_CannotOverrideAction(object p0, object p1, object p2, object p3, object p4, object p5, object p6) { - return string.Format(CultureInfo.CurrentCulture, GetString("FormTagHelper_CannotOverrideAction"), p0, p1, p2, p3, p4, p5); + return string.Format(CultureInfo.CurrentCulture, GetString("FormTagHelper_CannotOverrideAction"), p0, p1, p2, p3, p4, p5, p6); } /// diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx index 26528c6813..0a18b28c89 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx @@ -121,10 +121,10 @@ Cannot determine an '{4}' attribute for {0}. An {0} with a specified '{1}' must not have an '{2}' or '{3}' attribute. - Cannot override the '{8}' attribute 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 override the '{9}' attribute for {0}. An {0} with a specified '{9}' must not have attributes starting with '{8}' or an '{1}', '{2}', '{3}', '{4}', '{5}', '{6}', or '{7}' attribute. - Cannot override the '{1}' attribute for {0}. A {0} with a specified '{1}' must not have attributes starting with '{5}' or an '{2}' or '{3}' or '{4}' attribute. + Cannot override the '{1}' attribute for {0}. A {0} with a specified '{1}' must not have attributes starting with '{6}' or an '{2}' or '{3}' or '{4}' or '{5}' attribute. Unexpected '{1}' expression result type '{2}' for {0}. '{1}' must be of type '{3}' if '{4}' is '{5}'. diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/AnchorTagHelperTest.cs b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/AnchorTagHelperTest.cs index 18ae391e8f..bd4596b6e4 100644 --- a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/AnchorTagHelperTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/AnchorTagHelperTest.cs @@ -187,6 +187,178 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers Assert.True(output.Content.IsEmpty); } + [Fact] + public async Task ProcessAsync_AddsAreaToRouteValuesAndCallsIntoActionLinkWithExpectedParameters() + { + // Arrange + var context = new TagHelperContext( + allAttributes: new TagHelperAttributeList( + Enumerable.Empty()), + items: new Dictionary(), + uniqueId: "test"); + var output = new TagHelperOutput( + "a", + attributes: new TagHelperAttributeList(), + getChildContentAsync: (useCachedResult, encoder) => + { + var tagHelperContent = new DefaultTagHelperContent(); + tagHelperContent.SetContent("Something"); + return Task.FromResult(tagHelperContent); + }); + output.Content.SetContent(string.Empty); + + var generator = new Mock(); + var expectedRouteValues = new Dictionary { { "area", "Admin" } }; + + generator + .Setup(mock => mock.GenerateActionLink( + It.IsAny(), + string.Empty, + "Index", + "Home", + "http", + "contoso.com", + "hello=world", + expectedRouteValues, + null)) + .Returns(new TagBuilder("a")) + .Verifiable(); + var anchorTagHelper = new AnchorTagHelper(generator.Object) + { + Action = "Index", + Controller = "Home", + Area = "Admin", + Fragment = "hello=world", + Host = "contoso.com", + Protocol = "http", + }; + + // Act + await anchorTagHelper.ProcessAsync(context, output); + + // Assert + generator.Verify(); + + Assert.Equal("a", output.TagName); + Assert.Empty(output.Attributes); + Assert.True(output.Content.IsEmpty); + } + + [Fact] + public async Task ProcessAsync_AspAreaOverridesAspRouteArea() + { + // Arrange + var context = new TagHelperContext( + allAttributes: new TagHelperAttributeList( + Enumerable.Empty()), + items: new Dictionary(), + uniqueId: "test"); + var output = new TagHelperOutput( + "a", + attributes: new TagHelperAttributeList(), + getChildContentAsync: (useCachedResult, encoder) => + { + var tagHelperContent = new DefaultTagHelperContent(); + tagHelperContent.SetContent("Something"); + return Task.FromResult(tagHelperContent); + }); + output.Content.SetContent(string.Empty); + + var generator = new Mock(); + var expectedRouteValues = new Dictionary { { "area", "Admin" } }; + + generator + .Setup(mock => mock.GenerateActionLink( + It.IsAny(), + string.Empty, + "Index", + "Home", + "http", + "contoso.com", + "hello=world", + expectedRouteValues, + null)) + .Returns(new TagBuilder("a")) + .Verifiable(); + var anchorTagHelper = new AnchorTagHelper(generator.Object) + { + Action = "Index", + Controller = "Home", + Area = "Admin", + Fragment = "hello=world", + Host = "contoso.com", + Protocol = "http", + RouteValues = new Dictionary { { "area", "Home" } } + }; + + // Act + await anchorTagHelper.ProcessAsync(context, output); + + // Assert + generator.Verify(); + + Assert.Equal("a", output.TagName); + Assert.Empty(output.Attributes); + Assert.True(output.Content.IsEmpty); + } + + [Fact] + public async Task ProcessAsync_EmptyStringOnAspAreaIsPassedThroughToRouteValues() + { + // Arrange + var context = new TagHelperContext( + allAttributes: new TagHelperAttributeList( + Enumerable.Empty()), + items: new Dictionary(), + uniqueId: "test"); + var output = new TagHelperOutput( + "a", + attributes: new TagHelperAttributeList(), + getChildContentAsync: (useCachedResult, encoder) => + { + var tagHelperContent = new DefaultTagHelperContent(); + tagHelperContent.SetContent("Something"); + return Task.FromResult(tagHelperContent); + }); + output.Content.SetContent(string.Empty); + + var generator = new Mock(); + var expectedRouteValues = new Dictionary { { "area", string.Empty } }; + + generator + .Setup(mock => mock.GenerateActionLink( + It.IsAny(), + string.Empty, + "Index", + "Home", + "http", + "contoso.com", + "hello=world", + expectedRouteValues, + null)) + .Returns(new TagBuilder("a")) + .Verifiable(); + var anchorTagHelper = new AnchorTagHelper(generator.Object) + { + Action = "Index", + Controller = "Home", + Area = string.Empty, + Fragment = "hello=world", + Host = "contoso.com", + Protocol = "http" + }; + + // Act + await anchorTagHelper.ProcessAsync(context, output); + + // Assert + generator.Verify(); + + Assert.Equal("a", output.TagName); + Assert.Empty(output.Attributes); + Assert.True(output.Content.IsEmpty); + } + [Theory] [InlineData("Action")] [InlineData("Controller")] @@ -221,7 +393,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var expectedErrorMessage = "Cannot override the 'href' attribute for . An with a specified " + "'href' must not have attributes starting with 'asp-route-' or an " + - "'asp-action', 'asp-controller', 'asp-route', 'asp-protocol', 'asp-host', or " + + "'asp-action', 'asp-controller', 'asp-area', 'asp-route', 'asp-protocol', 'asp-host', or " + "'asp-fragment' attribute."; var context = new TagHelperContext( diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/FormTagHelperTest.cs b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/FormTagHelperTest.cs index 2a3c1503e3..d6d258b967 100644 --- a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/FormTagHelperTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/FormTagHelperTest.cs @@ -283,6 +283,175 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers Assert.Empty(output.PostContent.GetContent()); } + [Fact] + public async Task ProcessAsync_AspAreaAddsAreaToRouteValues() + { + // Arrange + var viewContext = CreateViewContext(); + var context = new TagHelperContext( + allAttributes: new TagHelperAttributeList( + Enumerable.Empty()), + items: new Dictionary(), + uniqueId: "test"); + var output = new TagHelperOutput( + "form", + attributes: new TagHelperAttributeList(), + getChildContentAsync: (useCachedResult, encoder) => + { + var tagHelperContent = new DefaultTagHelperContent(); + tagHelperContent.SetContent("Something"); + return Task.FromResult(tagHelperContent); + }); + + var expectedRouteValues = new Dictionary { { "area", "Admin" } }; + var generator = new Mock(MockBehavior.Strict); + generator + .Setup(mock => mock.GenerateForm( + viewContext, + "Index", + "Home", + expectedRouteValues, + null, + null)) + .Returns(new TagBuilder("form")) + .Verifiable(); + var formTagHelper = new FormTagHelper(generator.Object) + { + Action = "Index", + Antiforgery = false, + Controller = "Home", + Area = "Admin", + ViewContext = viewContext, + }; + + // Act + await formTagHelper.ProcessAsync(context, output); + + // Assert + generator.Verify(); + + Assert.Equal("form", output.TagName); + Assert.Equal(TagMode.StartTagAndEndTag, output.TagMode); + Assert.Empty(output.Attributes); + Assert.Empty(output.PreElement.GetContent()); + Assert.Empty(output.PreContent.GetContent()); + Assert.True(output.Content.IsEmpty); + Assert.Empty(output.PostContent.GetContent()); + } + + [Fact] + public async Task ProcessAsync_EmptyStringOnAspAreaIsPassedThroughToRouteValues() + { + // Arrange + var viewContext = CreateViewContext(); + var context = new TagHelperContext( + allAttributes: new TagHelperAttributeList( + Enumerable.Empty()), + items: new Dictionary(), + uniqueId: "test"); + var output = new TagHelperOutput( + "form", + attributes: new TagHelperAttributeList(), + getChildContentAsync: (useCachedResult, encoder) => + { + var tagHelperContent = new DefaultTagHelperContent(); + tagHelperContent.SetContent("Something"); + return Task.FromResult(tagHelperContent); + }); + + var expectedRouteValues = new Dictionary { { "area", string.Empty } }; + var generator = new Mock(MockBehavior.Strict); + generator + .Setup(mock => mock.GenerateForm( + viewContext, + "Index", + "Home", + expectedRouteValues, + null, + null)) + .Returns(new TagBuilder("form")) + .Verifiable(); + var formTagHelper = new FormTagHelper(generator.Object) + { + Action = "Index", + Antiforgery = false, + Controller = "Home", + Area = string.Empty, + ViewContext = viewContext, + }; + + // Act + await formTagHelper.ProcessAsync(context, output); + + // Assert + generator.Verify(); + + Assert.Equal("form", output.TagName); + Assert.Equal(TagMode.StartTagAndEndTag, output.TagMode); + Assert.Empty(output.Attributes); + Assert.Empty(output.PreElement.GetContent()); + Assert.Empty(output.PreContent.GetContent()); + Assert.True(output.Content.IsEmpty); + Assert.Empty(output.PostContent.GetContent()); + } + + [Fact] + public async Task ProcessAsync_AspAreaOverridesAspRouteArea() + { + // Arrange + var viewContext = CreateViewContext(); + var context = new TagHelperContext( + allAttributes: new TagHelperAttributeList( + Enumerable.Empty()), + items: new Dictionary(), + uniqueId: "test"); + var output = new TagHelperOutput( + "form", + attributes: new TagHelperAttributeList(), + getChildContentAsync: (useCachedResult, encoder) => + { + var tagHelperContent = new DefaultTagHelperContent(); + tagHelperContent.SetContent("Something"); + return Task.FromResult(tagHelperContent); + }); + + var expectedRouteValues = new Dictionary { { "area", "Admin" } }; + var generator = new Mock(MockBehavior.Strict); + generator + .Setup(mock => mock.GenerateForm( + viewContext, + "Index", + "Home", + expectedRouteValues, + null, + null)) + .Returns(new TagBuilder("form")) + .Verifiable(); + var formTagHelper = new FormTagHelper(generator.Object) + { + Action = "Index", + Antiforgery = false, + Controller = "Home", + Area = "Admin", + RouteValues = new Dictionary { { "area", "Client" } }, + ViewContext = viewContext, + }; + + // Act + await formTagHelper.ProcessAsync(context, output); + + // Assert + generator.Verify(); + + Assert.Equal("form", output.TagName); + Assert.Equal(TagMode.StartTagAndEndTag, output.TagMode); + Assert.Empty(output.Attributes); + Assert.Empty(output.PreElement.GetContent()); + Assert.Empty(output.PreContent.GetContent()); + Assert.True(output.Content.IsEmpty); + Assert.Empty(output.PostContent.GetContent()); + } + [Fact] public async Task ProcessAsync_CallsIntoGenerateRouteFormWithExpectedParameters() { @@ -415,7 +584,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var expectedErrorMessage = "Cannot override the 'action' attribute for . A with a specified " + "'action' must not have attributes starting with 'asp-route-' or an " + - "'asp-action' or 'asp-controller' or 'asp-route' attribute."; + "'asp-action' or 'asp-controller' or 'asp-area' or 'asp-route' attribute."; var context = new TagHelperContext( allAttributes: new TagHelperAttributeList(