In NavLink, support matching either complete URLs or just URL prefixes (both are necessary for typical nav menus)

This commit is contained in:
Steve Sanderson 2018-03-20 12:55:33 +00:00
parent d921705881
commit 5bf0b891e9
7 changed files with 116 additions and 21 deletions

View File

@ -13,7 +13,7 @@
<div class='navbar-collapse collapse'>
<ul class='nav navbar-nav'>
<li>
<NavLink href="/">
<NavLink href="/" Match=NavLinkMatch.All>
<span class='glyphicon glyphicon-home'></span> Home
</NavLink>
</li>

View File

@ -22,8 +22,8 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Services
static bool _hasEnabledNavigationInterception;
static string _cachedAbsoluteUri;
static EventHandler<string> _onLocationChanged;
static string _baseUriString;
static Uri _baseUri;
static string _baseUriStringNoTrailingSlash; // No trailing slash so we can just prepend it to suffixes
static Uri _baseUriWithTrailingSlash; // With trailing slash so it can be used in new Uri(base, relative)
/// <inheritdoc />
public event EventHandler<string> OnLocationChanged
@ -47,7 +47,7 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Services
public string GetBaseUriPrefix()
{
EnsureBaseUriPopulated();
return _baseUriString;
return _baseUriStringNoTrailingSlash;
}
/// <inheritdoc />
@ -77,7 +77,7 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Services
public Uri ToAbsoluteUri(string relativeUri)
{
EnsureBaseUriPopulated();
return new Uri(_baseUri, relativeUri);
return new Uri(_baseUriWithTrailingSlash, relativeUri);
}
/// <inheritdoc />
@ -118,12 +118,12 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Services
private static void EnsureBaseUriPopulated()
{
// The <base href> is fixed for the lifetime of the page, so just cache it
if (_baseUriString == null)
if (_baseUriStringNoTrailingSlash == null)
{
var baseUri = RegisteredFunction.InvokeUnmarshalled<string>(
$"{_functionPrefix}.getBaseURI");
_baseUriString = ToBaseUriPrefix(baseUri);
_baseUri = new Uri(_baseUriString);
_baseUriStringNoTrailingSlash = ToBaseUriPrefix(baseUri);
_baseUriWithTrailingSlash = new Uri(_baseUriStringNoTrailingSlash + "/");
}
}

View File

@ -32,6 +32,11 @@ namespace Microsoft.AspNetCore.Blazor.Routing
private string _hrefAbsolute;
private IReadOnlyDictionary<string, object> _allAttributes;
/// <summary>
/// Gets or sets a value representing the URL matching behavior.
/// </summary>
public NavLinkMatch Match { get; set; }
[Inject] private IUriHelper UriHelper { get; set; }
/// <inheritdoc />
@ -50,11 +55,12 @@ namespace Microsoft.AspNetCore.Blazor.Routing
parameters.TryGetValue(RenderTreeBuilder.ChildContent, out _childContent);
parameters.TryGetValue("class", out _cssClass);
parameters.TryGetValue("href", out string href);
Match = parameters.GetValueOrDefault(nameof(Match), NavLinkMatch.Prefix);
_allAttributes = parameters.ToDictionary();
// Update computed state and render
_hrefAbsolute = href == null ? null : UriHelper.ToAbsoluteUri(href).AbsoluteUri;
_isActive = UriHelper.GetAbsoluteUri().Equals(_hrefAbsolute, StringComparison.Ordinal);
_isActive = ShouldMatch(UriHelper.GetAbsoluteUri());
_renderHandle.Render(Render);
}
@ -64,11 +70,11 @@ namespace Microsoft.AspNetCore.Blazor.Routing
UriHelper.OnLocationChanged -= OnLocationChanged;
}
private void OnLocationChanged(object sender, string newUri)
private void OnLocationChanged(object sender, string newUriAbsolute)
{
// We could just re-render always, but for this component we know the
// only relevant state change is to the _isActive property.
var shouldBeActiveNow = newUri.Equals(_hrefAbsolute, StringComparison.Ordinal);
var shouldBeActiveNow = ShouldMatch(newUriAbsolute);
if (shouldBeActiveNow != _isActive)
{
_isActive = shouldBeActiveNow;
@ -76,6 +82,22 @@ namespace Microsoft.AspNetCore.Blazor.Routing
}
}
private bool ShouldMatch(string currentUriAbsolute)
{
if (Match == NavLinkMatch.Prefix)
{
return StartsWithAndHasSeparator(currentUriAbsolute, _hrefAbsolute);
}
else if (Match == NavLinkMatch.All)
{
return string.Equals(currentUriAbsolute, _hrefAbsolute, StringComparison.Ordinal);
}
else
{
throw new InvalidOperationException($"Unsupported {nameof(NavLinkMatch)} value: {Match}");
}
}
private void Render(RenderTreeBuilder builder)
{
builder.OpenElement(0, "a");
@ -98,5 +120,32 @@ namespace Microsoft.AspNetCore.Blazor.Routing
private string CombineWithSpace(string str1, string str2)
=> str1 == null ? str2
: (str2 == null ? str1 : $"{str1} {str2}");
private static bool StartsWithAndHasSeparator(string value, string prefix)
{
var valueLength = value.Length;
var prefixLength = prefix.Length;
if (prefixLength == valueLength)
{
return string.Equals(value, prefix, StringComparison.Ordinal);
}
else if (valueLength > prefixLength)
{
return value.StartsWith(prefix, StringComparison.Ordinal)
&& (
// Only match when there's a separator character either at the end of the
// prefix or right after it.
// Example: "/abc" is treated as a prefix of "/abc/def" but not "/abcdef"
// Example: "/abc/" is treated as a prefix of "/abc/def" but not "/abcdef"
prefixLength == 0
|| !char.IsLetterOrDigit(prefix[prefixLength - 1])
|| !char.IsLetterOrDigit(value[prefixLength])
);
}
else
{
return false;
}
}
}
}

View File

@ -0,0 +1,23 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
namespace Microsoft.AspNetCore.Blazor.Routing
{
/// <summary>
/// Modifies the URL matching behavior for a <see cref="NavLink"/>.
/// </summary>
public enum NavLinkMatch
{
/// <summary>
/// Specifies that the <see cref="NavLink"/> should be active when it matches any prefix
/// of the current URL.
/// </summary>
Prefix,
/// <summary>
/// Specifies that the <see cref="NavLink"/> should be active when it matches the entire
/// current URL.
/// </summary>
All,
}
}

View File

@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Linq;
using BasicTestApp;
using BasicTestApp.RouterTest;
using Microsoft.AspNetCore.Blazor.E2ETest.Infrastructure;
@ -30,6 +31,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
var app = MountTestComponent<TestRouter>();
Assert.Equal("This is the default page.", app.FindElement(By.Id("test-info")).Text);
AssertHighlightedLinks("Default (matches all)");
}
[Fact]
@ -39,6 +41,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
var app = MountTestComponent<TestRouter>();
Assert.Equal("Your full name is Dan Roth.", app.FindElement(By.Id("test-info")).Text);
AssertHighlightedLinks();
}
[Fact]
@ -48,6 +51,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
var app = MountTestComponent<TestRouter>();
Assert.Equal("This is another page.", app.FindElement(By.Id("test-info")).Text);
AssertHighlightedLinks("Other", "Other with base-relative URL (matches all)");
}
[Fact]
@ -58,6 +62,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
var app = MountTestComponent<TestRouter>();
app.FindElement(By.LinkText("Other")).Click();
Assert.Equal("This is another page.", app.FindElement(By.Id("test-info")).Text);
AssertHighlightedLinks("Other", "Other with base-relative URL (matches all)");
}
[Fact]
@ -66,8 +71,9 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
SetUrlViaPushState($"{ServerPathBase}/RouterTest/");
var app = MountTestComponent<TestRouter>();
app.FindElement(By.LinkText("Other with base-relative URL")).Click();
app.FindElement(By.LinkText("Other with base-relative URL (matches all)")).Click();
Assert.Equal("This is another page.", app.FindElement(By.Id("test-info")).Text);
AssertHighlightedLinks("Other", "Other with base-relative URL (matches all)");
}
[Fact]
@ -78,6 +84,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
var app = MountTestComponent<TestRouter>();
app.FindElement(By.LinkText("With parameters")).Click();
Assert.Equal("Your full name is Steve Sanderson.", app.FindElement(By.Id("test-info")).Text);
AssertHighlightedLinks("With parameters");
}
[Fact]
@ -86,8 +93,9 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
SetUrlViaPushState($"{ServerPathBase}/RouterTest/Other");
var app = MountTestComponent<TestRouter>();
app.FindElement(By.LinkText("Default")).Click();
app.FindElement(By.LinkText("Default (matches all)")).Click();
Assert.Equal("This is the default page.", app.FindElement(By.Id("test-info")).Text);
AssertHighlightedLinks("Default (matches all)");
}
[Fact]
@ -98,6 +106,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
var app = MountTestComponent<TestRouter>();
app.FindElement(By.LinkText("Other with query")).Click();
Assert.Equal("This is another page.", app.FindElement(By.Id("test-info")).Text);
AssertHighlightedLinks("Other", "Other with query");
}
[Fact]
@ -108,6 +117,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
var app = MountTestComponent<TestRouter>();
app.FindElement(By.LinkText("Default with query")).Click();
Assert.Equal("This is the default page.", app.FindElement(By.Id("test-info")).Text);
AssertHighlightedLinks("Default with query");
}
[Fact]
@ -118,6 +128,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
var app = MountTestComponent<TestRouter>();
app.FindElement(By.LinkText("Other with hash")).Click();
Assert.Equal("This is another page.", app.FindElement(By.Id("test-info")).Text);
AssertHighlightedLinks("Other", "Other with hash");
}
[Fact]
@ -128,6 +139,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
var app = MountTestComponent<TestRouter>();
app.FindElement(By.LinkText("Default with hash")).Click();
Assert.Equal("This is the default page.", app.FindElement(By.Id("test-info")).Text);
AssertHighlightedLinks("Default with hash");
}
[Fact]
@ -138,6 +150,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
var app = MountTestComponent<TestRouter>();
app.FindElement(By.TagName("button")).Click();
Assert.Equal("This is another page.", app.FindElement(By.Id("test-info")).Text);
AssertHighlightedLinks("Other", "Other with base-relative URL (matches all)");
}
public void Dispose()
@ -153,5 +166,12 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
var absoluteUri = new Uri(_server.RootUri, relativeUri);
jsExecutor.ExecuteScript($"Blazor.navigateTo('{absoluteUri.ToString()}')");
}
private void AssertHighlightedLinks(params string[] linkTexts)
{
var actual = Browser.FindElements(By.CssSelector("a.active"));
var actualTexts = actual.Select(x => x.Text);
Assert.Equal(linkTexts, actualTexts);
}
}
}

View File

@ -1,14 +1,16 @@
@page "/Links"
@using Microsoft.AspNetCore.Blazor.Routing
@inject Microsoft.AspNetCore.Blazor.Services.IUriHelper uriHelper
<style type="text/css">a.active { background-color: yellow; font-weight: bold; }</style>
<ul>
<li><a href="/subdir/RouterTest/">Default</a></li>
<li><a href="/subdir/RouterTest/?abc=123">Default with query</a></li>
<li><a href="/subdir/RouterTest/#blah">Default with hash</a></li>
<li><a href="/subdir/RouterTest/Other">Other</a></li>
<li><a href="RouterTest/Other">Other with base-relative URL</a></li>
<li><a href="/subdir/RouterTest/Other?abc=123">Other with query</a></li>
<li><a href="/subdir/RouterTest/Other#blah">Other with hash</a></li>
<li><a href="/subdir/RouterTest/WithParameters/Name/Steve/LastName/Sanderson">With parameters</a></li>
<li><NavLink href="/subdir/RouterTest/" Match=NavLinkMatch.All>Default (matches all)</NavLink></li>
<li><NavLink href="/subdir/RouterTest/?abc=123">Default with query</NavLink></li>
<li><NavLink href="/subdir/RouterTest/#blah">Default with hash</NavLink></li>
<li><NavLink href="/subdir/RouterTest/Other">Other</NavLink></li>
<li><NavLink href="RouterTest/Other" Match=NavLinkMatch.All>Other with base-relative URL (matches all)</NavLink></li>
<li><NavLink href="/subdir/RouterTest/Other?abc=123">Other with query</NavLink></li>
<li><NavLink href="/subdir/RouterTest/Other#blah">Other with hash</NavLink></li>
<li><NavLink href="/subdir/RouterTest/WithParameters/Name/Steve/LastName/Sanderson">With parameters</NavLink></li>
</ul>
<button onclick=@{ uriHelper.NavigateTo("RouterTest/Other"); }>

View File

@ -1,6 +1,7 @@
@page "/RouterTest/WithParameters/Name/{firstName}/LastName/{lastName}"
@using BasicTestApp.RouterTest
<div id="test-info">Your full name is @FirstName @LastName.</div>
<Links />
@functions
{