Skip to content

Commit

Permalink
Feature/navigation lock (#809)
Browse files Browse the repository at this point in the history
* feat: Added navigation interception

* Use parameter instead of field

Co-authored-by: Egil Hansen <egil@assimilated.dk>

* Remove whitespace

Co-authored-by: Egil Hansen <egil@assimilated.dk>

* fix: use static import to remove noise

* feat: Added exception handling for NavigationLock

* fix: remove obsolete property for test

* add: Changelog entry

* add: Documentation for NavigationLock

* refactor: Renamed Failed to Faulted for state

* fix: small tweaks to code docs and names

* fix: spelling

* remve lutconfig

Co-authored-by: Egil Hansen <egil@assimilated.dk>
  • Loading branch information
linkdotnet and egil authored Sep 15, 2022
1 parent 586bd91 commit 2931a45
Show file tree
Hide file tree
Showing 9 changed files with 452 additions and 120 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ All notable changes to **bUnit** will be documented in this file. The project ad

By [@linkdotnet](https://github.com/linkdotnet) and [@egil](https://github.com/egil).

- Added support for `NavigationLock`, which allows user code to intercept and prevent navigation. By [@linkdotnet](https://github.com/linkdotnet) and [@egil](https://github.com/egil).

### Fixed

- `JSInterop.VerifyInvoke` reported the wrong number of actual invocations of a given identifier. Reported by [@otori](https://github.com/otori). Fixed by [@linkdotnet](https://github.com/linkdotnet).
Expand Down
66 changes: 66 additions & 0 deletions docs/site/docs/test-doubles/fake-navigation-manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,69 @@ Assert.Equal("http://localhost/foo", navMan.Uri);
```

If a component issues multiple `NavigateTo` calls, then it is possible to inspect the navigation history by accessing the <xref:Bunit.TestDoubles.FakeNavigationManager.History> property. It's a stack based structure, meaning the latest navigations will be first in the collection at index 0.

## Asserting that a navigation was prevented with the `NavigationLock` component

The `NavigationLock` component, which was introduced with .NET 7, gives the possibility to intercept the navigation and can even prevent it. bUnit will always create a history entry for prevented or even failed interceptions. This gets reflected in the <xref:Bunit.TestDoubles.NavigationHistory.NavigationState> property, as well as in case of an exception on the <xref:Bunit.TestDoubles.NavigationHistory.Exception> property.

A component can look like this:
```razor
@inject NavigationManager NavigationManager
<button @onclick="(() => NavigationManager.NavigateTo("/counter"))">Counter</button>
<NavigationLock OnBeforeInternalNavigation="InterceptNavigation"></NavigationLock>
@code {
private void InterceptNavigation(LocationChangingContext context)
{
context.PreventNavigation();
}
}
```

A typical test, which asserts that the navigation got prevented, would look like this:

```csharp
using var ctx = new TestContext();
var navMan = ctx.Services.GetRequiredService<FakeNavigationManager>();
var cut = ctx.RenderComponent<InterceptComponent>();

cut.Find("button").Click();

// Assert that the navigation was prevented
var navigationHistory = navMan.History.Single();
Assert.Equal(NavigationState.Prevented, navigationHistory.NavigationState);
```

## Simulate preventing navigation from a `<a href>` with the `NavigationLock` component

As `<a href>` navigation is not natively supported in bUnit, the `NavigationManager` can be used to simulate the exact behavior.

```razor
<a href="/counter">Counter</a>
<NavigationLock OnBeforeInternalNavigation="InterceptNavigation"></NavigationLock>
@code {
private void InterceptNavigation(LocationChangingContext context)
{
throw new Exception();
}
}
```

The test utilizes the `NavigationManager` itself to achieve the same:

```csharp
using var ctx = new TestContext();
var navMan = ctx.Services.GetRequiredService<FakeNavigationManager>();
var cut = ctx.RenderComponent<InterceptAHRefComponent>();

navMan.NavigateTo("/counter");

// Assert that the navigation was prevented
var navigationHistory = navMan.History.Single();
Assert.Equal(NavigationState.Faulted, navigationHistory.NavigationState);
Assert.NotNull(navigationHistory.Exception);
```
4 changes: 4 additions & 0 deletions src/bunit.web/JSInterop/BunitJSInterop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ private void AddCustomNet5Handlers()
private void AddCustomNet6Handlers()
{
AddInvocationHandler(new FocusOnNavigateHandler());
#if NET7_0_OR_GREATER
AddInvocationHandler(new NavigationLockDisableNavigationPromptInvocationHandler());
AddInvocationHandler(new NavigationLockEnableNavigationPromptInvocationHandler());
#endif
}
#endif
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#if NET7_0_OR_GREATER
namespace Bunit.JSInterop.InvocationHandlers.Implementation;

internal sealed class NavigationLockDisableNavigationPromptInvocationHandler : JSRuntimeInvocationHandler
{
private const string Identifier = "Blazor._internal.NavigationLock.disableNavigationPrompt";

internal NavigationLockDisableNavigationPromptInvocationHandler()
: base(inv => inv.Identifier.Equals(Identifier, StringComparison.Ordinal), isCatchAllHandler: true)
{
SetVoidResult();
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#if NET7_0_OR_GREATER
namespace Bunit.JSInterop.InvocationHandlers.Implementation;

internal sealed class NavigationLockEnableNavigationPromptInvocationHandler : JSRuntimeInvocationHandler
{
private const string Identifier = "Blazor._internal.NavigationLock.enableNavigationPrompt";

internal NavigationLockEnableNavigationPromptInvocationHandler()
: base(inv => inv.Identifier.Equals(Identifier, StringComparison.Ordinal), isCatchAllHandler: true)
{
SetVoidResult();
}
}
#endif
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Bunit.Rendering;
using Microsoft.AspNetCore.Components.Routing;

namespace Bunit.TestDoubles;

Expand Down Expand Up @@ -68,7 +69,6 @@ protected override void NavigateToCore(string uri, bool forceLoad)
#endif

#if NET6_0_OR_GREATER

/// <inheritdoc/>
protected override void NavigateToCore(string uri, NavigationOptions options)
{
Expand All @@ -85,12 +85,37 @@ protected override void NavigateToCore(string uri, NavigationOptions options)
if (options.ReplaceHistoryEntry && history.Count > 0)
history.Pop();

history.Push(new NavigationHistory(uri, options));

#if NET7_0_OR_GREATER
renderer.Dispatcher.InvokeAsync(async () =>
#else
renderer.Dispatcher.InvokeAsync(() =>
#endif
{
Uri = absoluteUri.OriginalString;

#if NET7_0_OR_GREATER
var shouldContinueNavigation = false;
try
{
shouldContinueNavigation = await NotifyLocationChangingAsync(uri, options.HistoryEntryState, isNavigationIntercepted: false).ConfigureAwait(false);
}
catch (Exception exception)
{
history.Push(new NavigationHistory(uri, options, NavigationState.Faulted, exception));
return;
}

history.Push(new NavigationHistory(uri, options, shouldContinueNavigation ? NavigationState.Succeeded : NavigationState.Prevented));

if (!shouldContinueNavigation)
{
return;
}
#else
history.Push(new NavigationHistory(uri, options));
#endif


// Only notify of changes if user navigates within the same
// base url (domain). Otherwise, the user navigated away
// from the app, and Blazor's NavigationManager would
Expand All @@ -107,6 +132,15 @@ protected override void NavigateToCore(string uri, NavigationOptions options)
}
#endif

#if NET7_0_OR_GREATER
/// <inheritdoc/>
protected override void SetNavigationLockState(bool value) {}

/// <inheritdoc/>
protected override void HandleLocationChangingHandlerException(Exception ex, LocationChangingContext context)
=> throw ex;
#endif

private URI GetNewAbsoluteUri(string uri)
=> URI.IsWellFormedUriString(uri, UriKind.Relative)
? ToAbsoluteUri(uri)
Expand All @@ -124,4 +158,4 @@ private static string GetBaseUri(URI uri)
{
return uri.Scheme + "://" + uri.Authority + "/";
}
}
}
49 changes: 45 additions & 4 deletions src/bunit.web/TestDoubles/NavigationManager/NavigationHistory.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Components.Routing;

namespace Bunit.TestDoubles;

/// <summary>
Expand All @@ -18,27 +20,66 @@ public sealed class NavigationHistory : IEquatable<NavigationHistory>
public Bunit.TestDoubles.NavigationOptions Options { get; }
#endif
#if NET6_0_OR_GREATER
public Microsoft.AspNetCore.Components.NavigationOptions Options { get; }
public NavigationOptions Options { get; }
#endif

#if NET7_0_OR_GREATER
/// <summary>
/// Gets the <see cref="NavigationState"/> associated with this history entry.
/// </summary>
public NavigationState State { get; }

/// <summary>
/// Gets the exception thrown from the <see cref="NavigationLock.OnBeforeInternalNavigation"/> handler, if any.
/// </summary>
/// <remarks>
/// Will not be null when <see cref="State"/> is <see cref="NavigationState.Faulted"/>.
/// </remarks>
public Exception? Exception { get; }
#endif

#if !NET6_0_OR_GREATER
/// <summary>
/// Initializes a new instance of the <see cref="NavigationHistory"/> class.
/// </summary>
/// <param name="uri"></param>
/// <param name="options"></param>
[SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "Using string to align with NavigationManager")]
#if !NET6_0_OR_GREATER
public NavigationHistory(string uri, Bunit.TestDoubles.NavigationOptions options)
{
Uri = uri;
Options = options;
}
#endif
#if NET6_0_OR_GREATER
public NavigationHistory(string uri, Microsoft.AspNetCore.Components.NavigationOptions options)
#if NET6_0
/// <summary>
/// Initializes a new instance of the <see cref="NavigationHistory"/> class.
/// </summary>
/// <param name="uri"></param>
/// <param name="options"></param>
[SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "Using string to align with NavigationManager")]
public NavigationHistory(string uri, NavigationOptions options)
{
Uri = uri;
Options = options;
}
#endif

#if NET7_0_OR_GREATER
/// <summary>
/// Initializes a new instance of the <see cref="NavigationHistory"/> class.
/// </summary>
/// <param name="uri"></param>
/// <param name="options"></param>
/// <param name="navigationState"></param>
/// <param name="exception"></param>
[SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "Using string to align with NavigationManager")]
public NavigationHistory(string uri, NavigationOptions options, NavigationState navigationState, Exception? exception = null)
{
Uri = uri;
Options = options;
State = navigationState;
Exception = exception;
}
#endif

Expand Down
24 changes: 24 additions & 0 deletions src/bunit.web/TestDoubles/NavigationManager/NavigationState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#if NET7_0_OR_GREATER
namespace Bunit.TestDoubles;

/// <summary>
/// Describes the possible enumerations when a navigation gets intercepted.
/// </summary>
public enum NavigationState
{
/// <summary>
/// The navigation was successfully executed.
/// </summary>
Succeeded,

/// <summary>
/// The navigation was prevented.
/// </summary>
Prevented,

/// <summary>
/// The OnBeforeInternalNavigation event handler threw an exception and the navigation did not complete.
/// </summary>
Faulted
}
#endif
Loading

0 comments on commit 2931a45

Please sign in to comment.