In my previous post about my Bootstrap Active Link TagHelper, I described using a `active-prefix` properties to determine if a ‘parent’ menu should be flagged active if one of its child menus were active. This had a couple flaws.
Shared Pages
If you have a Razor View that serves up content for multiple pages, this can be problematic. Previously, I was comparing the `asp-page` value with the `ViewContextData.ActionDescriptor.DisplayName` value. However, if you have multiple menus built using the same page (but different route values) this is a problem. Consider the following:
<bs-menu-link asp-page="/Schedules/Descriptions" asp-route-group="Junior" menu-text="Juniors"></bs-menu-link>
<bs-menu-link asp-page="/Schedules/Descriptions" asp-route-group="Adult" menu-text="Adults"></bs-menu-link>
<bs-menu-link asp-page="/Schedules/Descriptions" asp-route-group="Senior" menu-text="Seniors"></bs-menu-link>
As you can see, when *any* of these menus are clicked, the `asp-page` and the `ViewContextData.ActionDescriptor.DisplayName` values are going to be “/Schedules/Descriptions” for every one resulting in all three being flagged as active.
Route Values
Before, I was using something similar to the following to determine if a ‘parent’ page was active.
var currentPage = ViewContextData.ActionDescriptor.DisplayName;
var active = currentPage.StartsWith( ActivePrefix, StringComparison.InvariantCultureIgnoreCase );
As you can guess, route values are not considered on this value. So I had to use something like this:
var active = ViewContextData.HttpContext.Request.Path.StartsWith( ActivePrefix );
Route Value in Middle of Url
The final problem I discovered, is that I changed my route values and Urls so that a ‘prefix’ no longer worked. For example:
Route: /Schedules/Program/{id}/{location}
Request.Path: /Schedules/Program/Junior.JD/Outdoor
To highlight the ‘Juniors’ menu link, I could have used an `active-prefix` of ‘/Schedules/Program/Junior’ and it would have matched all ‘program’ Urls. However, I want the Juniors link to also highlight when Request.Path is /Schedules/Descriptions/Junior.
Regex to the Rescue
To solve my problem, I changed `ActivePrefix` to `ActivePattern` and perform a Regex match on the Request.Path. Additionally, to solve the ‘Shared Pages’ problem, if there is an ActivePattern property specified, I do not check for the `asp-page` being the same as the `ViewContextData.ActionDescriptor.DisplayName`.
To accomplish the same menu above with the new syntax, I would do something like this:
<bs-menu-link active-pattern="\/Schedules\/.*\/Junior" asp-page="/Schedules/Descriptions" asp-route-group="Junior" menu-text="Juniors"></bs-menu-link>
<bs-menu-link active-pattern="\/Schedules\/.*\/Adult" asp-page="/Schedules/Descriptions" asp-route-group="Adult" menu-text="Adults"></bs-menu-link>
<bs-menu-link active-pattern="\/Schedules\/.*\/Senior" asp-page="/Schedules/Descriptions" asp-route-group="Senior" menu-text="Seniors"></bs-menu-link>
And finally, the full source code:
[HtmlTargetElement( "bs-menu-link" )]
public class BootstrapMenuLinkTagHelper : TagHelper
{
private readonly IHtmlGenerator htmlGenerator;
public BootstrapMenuLinkTagHelper( IHtmlGenerator htmlGenerator )
{
this.htmlGenerator = htmlGenerator;
}
[HtmlAttributeName( "menu-text" )]
public string MenuText { get; set; }
[HtmlAttributeName( "active-pattern" )]
public string ActivePattern { get; set; }
[ViewContext]
[HtmlAttributeNotBound]
public ViewContext ViewContextData { get; set; }
#region - Properties shared with AnchorTagHelper -
private const string ActionAttributeName = "asp-action";
private const string ControllerAttributeName = "asp-controller";
private const string AreaAttributeName = "asp-area";
private const string PageAttributeName = "asp-page";
private const string PageHandlerAttributeName = "asp-page-handler";
private const string FragmentAttributeName = "asp-fragment";
private const string HostAttributeName = "asp-host";
private const string ProtocolAttributeName = "asp-protocol";
private const string RouteAttributeName = "asp-route";
private const string RouteValuesDictionaryName = "asp-all-route-data";
private const string RouteValuesPrefix = "asp-route-";
private IDictionary<string, string> _routeValues;
[HtmlAttributeName( ActionAttributeName )]
public string Action { get; set; }
[HtmlAttributeName( ControllerAttributeName )]
public string Controller { get; set; }
[HtmlAttributeName( AreaAttributeName )]
public string Area { get; set; }
[HtmlAttributeName( PageAttributeName )]
public string Page { get; set; }
[HtmlAttributeName( PageHandlerAttributeName )]
public string PageHandler { get; set; }
[HtmlAttributeName( ProtocolAttributeName )]
public string Protocol { get; set; }
[HtmlAttributeName( HostAttributeName )]
public string Host { get; set; }
[HtmlAttributeName( FragmentAttributeName )]
public string Fragment { get; set; }
[HtmlAttributeName( RouteAttributeName )]
public string Route { get; set; }
[HtmlAttributeName( RouteValuesDictionaryName, DictionaryAttributePrefix = RouteValuesPrefix )]
public IDictionary<string, string> RouteValues
{
get
{
if ( _routeValues == null )
{
_routeValues = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
}
return _routeValues;
}
set
{
_routeValues = value;
}
}
#endregion
public async override Task ProcessAsync( TagHelperContext context, TagHelperOutput output )
{
output.TagName = "li";
if ( MenuText != null )
{
var currentPage = ViewContextData.ActionDescriptor.DisplayName;
// TODO: Might need to add logic for Areas, but not using yet, so leaving as is.
var isPageActive =
( !string.IsNullOrEmpty( Page ) && string.IsNullOrEmpty( ActivePattern ) && string.Compare( currentPage, Page, true ) == 0 ) ||
( !string.IsNullOrEmpty( ActivePattern ) && new Regex( ActivePattern, RegexOptions.IgnoreCase ).IsMatch( ViewContextData.HttpContext.Request.Path ) );
// TODO: Only using Razor Pages right now, so this logic might not be exactly right
var isControllerActive =
string.IsNullOrEmpty( Page ) &&
!string.IsNullOrWhiteSpace( Controller ) &&
string.Compare( Controller, (string)ViewContextData.RouteData.Values[ "controller" ], true ) == 0 &&
string.Compare( Action, (string)ViewContextData.RouteData.Values[ "action" ], true ) == 0;
if ( isPageActive || isControllerActive )
{
if ( output.Attributes.ContainsName( "class" ) )
{
output.Attributes.SetAttribute( "class", <pre wp-pre-tag-4=""></pre>quot;{output.Attributes[ "class" ].Value} active" );
}
else
{
output.Attributes.SetAttribute( "class", "active" );
}
}
if ( !string.IsNullOrWhiteSpace( Page ) || !string.IsNullOrWhiteSpace( Controller ) )
{
// Declaring and using a builtin AnchorTagHelper following patterns from:
// https://stackoverflow.com/a/56910392/166231
var anchorTagHelper = new AnchorTagHelper( htmlGenerator )
{
Action = Action,
Area = Area,
Controller = Controller,
Page = Page,
Fragment = Fragment,
Host = Host,
Protocol = Protocol,
Route = Route,
PageHandler = PageHandler,
RouteValues = RouteValues,
ViewContext = ViewContextData
};
var anchorOutput =
new TagHelperOutput( "a",
new TagHelperAttributeList(),
( useCachedResult, encoder ) =>
Task.Factory.StartNew<TagHelperContent>( () => new DefaultTagHelperContent() )
);
anchorOutput.Content.AppendHtml( MenuText );
var anchorAttributes = new[]
{
ActionAttributeName,
ControllerAttributeName,
AreaAttributeName,
PageAttributeName,
PageHandlerAttributeName,
FragmentAttributeName,
HostAttributeName,
ProtocolAttributeName,
RouteAttributeName,
RouteValuesDictionaryName
};
// TODO: Not sure if I have to pass in all these attributes since they are assigned on the anchorTagHelper
// I asked a question to the Stack Overflow post and am awaiting an answer.
var anchorAttributeList =
context.AllAttributes
.Where( a => anchorAttributes.Contains( a.Name ) || a.Name.StartsWith( RouteValuesPrefix, StringComparison.InvariantCultureIgnoreCase ) )
.Select( a => new TagHelperAttribute( a.Name, a.Value ) );
var anchorContext = new TagHelperContext(
new TagHelperAttributeList( anchorAttributeList ),
new Dictionary<object, object>(),
Guid.NewGuid().ToString() );
await anchorTagHelper.ProcessAsync( anchorContext, anchorOutput );
output.PreContent.SetHtmlContent( anchorOutput );
}
else
{
output.PreContent.SetHtmlContent( <pre wp-pre-tag-4=""></pre>quot;<div>{MenuText}</div>" );
}
}
}
}
One thought on “Migrating to .NET Core–Bootstrap Active Link TagHelper–ActivePattern Update”