Migrating to .NET Core–Bootstrap Active Link TagHelper–ActivePattern Update

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

Leave a comment