Skip to content

Commit 2931a45

Browse files
linkdotnetegil
andauthored
Feature/navigation lock (#809)
* feat: Added navigation interception * Use parameter instead of field Co-authored-by: Egil Hansen <[email protected]> * Remove whitespace Co-authored-by: Egil Hansen <[email protected]> * 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 <[email protected]>
1 parent 586bd91 commit 2931a45

File tree

9 files changed

+452
-120
lines changed

9 files changed

+452
-120
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ All notable changes to **bUnit** will be documented in this file. The project ad
3636

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

39+
- 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).
40+
3941
### Fixed
4042

4143
- `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).

docs/site/docs/test-doubles/fake-navigation-manager.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,69 @@ Assert.Equal("http://localhost/foo", navMan.Uri);
7171
```
7272

7373
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.
74+
75+
## Asserting that a navigation was prevented with the `NavigationLock` component
76+
77+
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.
78+
79+
A component can look like this:
80+
```razor
81+
@inject NavigationManager NavigationManager
82+
83+
<button @onclick="(() => NavigationManager.NavigateTo("/counter"))">Counter</button>
84+
85+
<NavigationLock OnBeforeInternalNavigation="InterceptNavigation"></NavigationLock>
86+
87+
@code {
88+
private void InterceptNavigation(LocationChangingContext context)
89+
{
90+
context.PreventNavigation();
91+
}
92+
}
93+
```
94+
95+
A typical test, which asserts that the navigation got prevented, would look like this:
96+
97+
```csharp
98+
using var ctx = new TestContext();
99+
var navMan = ctx.Services.GetRequiredService<FakeNavigationManager>();
100+
var cut = ctx.RenderComponent<InterceptComponent>();
101+
102+
cut.Find("button").Click();
103+
104+
// Assert that the navigation was prevented
105+
var navigationHistory = navMan.History.Single();
106+
Assert.Equal(NavigationState.Prevented, navigationHistory.NavigationState);
107+
```
108+
109+
## Simulate preventing navigation from a `<a href>` with the `NavigationLock` component
110+
111+
As `<a href>` navigation is not natively supported in bUnit, the `NavigationManager` can be used to simulate the exact behavior.
112+
113+
```razor
114+
<a href="/counter">Counter</a>
115+
116+
<NavigationLock OnBeforeInternalNavigation="InterceptNavigation"></NavigationLock>
117+
118+
@code {
119+
private void InterceptNavigation(LocationChangingContext context)
120+
{
121+
throw new Exception();
122+
}
123+
}
124+
```
125+
126+
The test utilizes the `NavigationManager` itself to achieve the same:
127+
128+
```csharp
129+
using var ctx = new TestContext();
130+
var navMan = ctx.Services.GetRequiredService<FakeNavigationManager>();
131+
var cut = ctx.RenderComponent<InterceptAHRefComponent>();
132+
133+
navMan.NavigateTo("/counter");
134+
135+
// Assert that the navigation was prevented
136+
var navigationHistory = navMan.History.Single();
137+
Assert.Equal(NavigationState.Faulted, navigationHistory.NavigationState);
138+
Assert.NotNull(navigationHistory.Exception);
139+
```

src/bunit.web/JSInterop/BunitJSInterop.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,10 @@ private void AddCustomNet5Handlers()
143143
private void AddCustomNet6Handlers()
144144
{
145145
AddInvocationHandler(new FocusOnNavigateHandler());
146+
#if NET7_0_OR_GREATER
147+
AddInvocationHandler(new NavigationLockDisableNavigationPromptInvocationHandler());
148+
AddInvocationHandler(new NavigationLockEnableNavigationPromptInvocationHandler());
149+
#endif
146150
}
147151
#endif
148152
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#if NET7_0_OR_GREATER
2+
namespace Bunit.JSInterop.InvocationHandlers.Implementation;
3+
4+
internal sealed class NavigationLockDisableNavigationPromptInvocationHandler : JSRuntimeInvocationHandler
5+
{
6+
private const string Identifier = "Blazor._internal.NavigationLock.disableNavigationPrompt";
7+
8+
internal NavigationLockDisableNavigationPromptInvocationHandler()
9+
: base(inv => inv.Identifier.Equals(Identifier, StringComparison.Ordinal), isCatchAllHandler: true)
10+
{
11+
SetVoidResult();
12+
}
13+
}
14+
#endif
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#if NET7_0_OR_GREATER
2+
namespace Bunit.JSInterop.InvocationHandlers.Implementation;
3+
4+
internal sealed class NavigationLockEnableNavigationPromptInvocationHandler : JSRuntimeInvocationHandler
5+
{
6+
private const string Identifier = "Blazor._internal.NavigationLock.enableNavigationPrompt";
7+
8+
internal NavigationLockEnableNavigationPromptInvocationHandler()
9+
: base(inv => inv.Identifier.Equals(Identifier, StringComparison.Ordinal), isCatchAllHandler: true)
10+
{
11+
SetVoidResult();
12+
}
13+
}
14+
#endif

src/bunit.web/TestDoubles/NavigationManager/FakeNavigationManager.cs

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Bunit.Rendering;
2+
using Microsoft.AspNetCore.Components.Routing;
23

34
namespace Bunit.TestDoubles;
45

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

7071
#if NET6_0_OR_GREATER
71-
7272
/// <inheritdoc/>
7373
protected override void NavigateToCore(string uri, NavigationOptions options)
7474
{
@@ -85,12 +85,37 @@ protected override void NavigateToCore(string uri, NavigationOptions options)
8585
if (options.ReplaceHistoryEntry && history.Count > 0)
8686
history.Pop();
8787

88-
history.Push(new NavigationHistory(uri, options));
89-
88+
#if NET7_0_OR_GREATER
89+
renderer.Dispatcher.InvokeAsync(async () =>
90+
#else
9091
renderer.Dispatcher.InvokeAsync(() =>
92+
#endif
9193
{
9294
Uri = absoluteUri.OriginalString;
9395

96+
#if NET7_0_OR_GREATER
97+
var shouldContinueNavigation = false;
98+
try
99+
{
100+
shouldContinueNavigation = await NotifyLocationChangingAsync(uri, options.HistoryEntryState, isNavigationIntercepted: false).ConfigureAwait(false);
101+
}
102+
catch (Exception exception)
103+
{
104+
history.Push(new NavigationHistory(uri, options, NavigationState.Faulted, exception));
105+
return;
106+
}
107+
108+
history.Push(new NavigationHistory(uri, options, shouldContinueNavigation ? NavigationState.Succeeded : NavigationState.Prevented));
109+
110+
if (!shouldContinueNavigation)
111+
{
112+
return;
113+
}
114+
#else
115+
history.Push(new NavigationHistory(uri, options));
116+
#endif
117+
118+
94119
// Only notify of changes if user navigates within the same
95120
// base url (domain). Otherwise, the user navigated away
96121
// from the app, and Blazor's NavigationManager would
@@ -107,6 +132,15 @@ protected override void NavigateToCore(string uri, NavigationOptions options)
107132
}
108133
#endif
109134

135+
#if NET7_0_OR_GREATER
136+
/// <inheritdoc/>
137+
protected override void SetNavigationLockState(bool value) {}
138+
139+
/// <inheritdoc/>
140+
protected override void HandleLocationChangingHandlerException(Exception ex, LocationChangingContext context)
141+
=> throw ex;
142+
#endif
143+
110144
private URI GetNewAbsoluteUri(string uri)
111145
=> URI.IsWellFormedUriString(uri, UriKind.Relative)
112146
? ToAbsoluteUri(uri)
@@ -124,4 +158,4 @@ private static string GetBaseUri(URI uri)
124158
{
125159
return uri.Scheme + "://" + uri.Authority + "/";
126160
}
127-
}
161+
}

src/bunit.web/TestDoubles/NavigationManager/NavigationHistory.cs

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using Microsoft.AspNetCore.Components.Routing;
2+
13
namespace Bunit.TestDoubles;
24

35
/// <summary>
@@ -18,27 +20,66 @@ public sealed class NavigationHistory : IEquatable<NavigationHistory>
1820
public Bunit.TestDoubles.NavigationOptions Options { get; }
1921
#endif
2022
#if NET6_0_OR_GREATER
21-
public Microsoft.AspNetCore.Components.NavigationOptions Options { get; }
23+
public NavigationOptions Options { get; }
2224
#endif
2325

26+
#if NET7_0_OR_GREATER
27+
/// <summary>
28+
/// Gets the <see cref="NavigationState"/> associated with this history entry.
29+
/// </summary>
30+
public NavigationState State { get; }
31+
32+
/// <summary>
33+
/// Gets the exception thrown from the <see cref="NavigationLock.OnBeforeInternalNavigation"/> handler, if any.
34+
/// </summary>
35+
/// <remarks>
36+
/// Will not be null when <see cref="State"/> is <see cref="NavigationState.Faulted"/>.
37+
/// </remarks>
38+
public Exception? Exception { get; }
39+
#endif
40+
41+
#if !NET6_0_OR_GREATER
2442
/// <summary>
2543
/// Initializes a new instance of the <see cref="NavigationHistory"/> class.
2644
/// </summary>
2745
/// <param name="uri"></param>
2846
/// <param name="options"></param>
2947
[SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "Using string to align with NavigationManager")]
30-
#if !NET6_0_OR_GREATER
3148
public NavigationHistory(string uri, Bunit.TestDoubles.NavigationOptions options)
3249
{
3350
Uri = uri;
3451
Options = options;
3552
}
3653
#endif
37-
#if NET6_0_OR_GREATER
38-
public NavigationHistory(string uri, Microsoft.AspNetCore.Components.NavigationOptions options)
54+
#if NET6_0
55+
/// <summary>
56+
/// Initializes a new instance of the <see cref="NavigationHistory"/> class.
57+
/// </summary>
58+
/// <param name="uri"></param>
59+
/// <param name="options"></param>
60+
[SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "Using string to align with NavigationManager")]
61+
public NavigationHistory(string uri, NavigationOptions options)
62+
{
63+
Uri = uri;
64+
Options = options;
65+
}
66+
#endif
67+
68+
#if NET7_0_OR_GREATER
69+
/// <summary>
70+
/// Initializes a new instance of the <see cref="NavigationHistory"/> class.
71+
/// </summary>
72+
/// <param name="uri"></param>
73+
/// <param name="options"></param>
74+
/// <param name="navigationState"></param>
75+
/// <param name="exception"></param>
76+
[SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "Using string to align with NavigationManager")]
77+
public NavigationHistory(string uri, NavigationOptions options, NavigationState navigationState, Exception? exception = null)
3978
{
4079
Uri = uri;
4180
Options = options;
81+
State = navigationState;
82+
Exception = exception;
4283
}
4384
#endif
4485

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#if NET7_0_OR_GREATER
2+
namespace Bunit.TestDoubles;
3+
4+
/// <summary>
5+
/// Describes the possible enumerations when a navigation gets intercepted.
6+
/// </summary>
7+
public enum NavigationState
8+
{
9+
/// <summary>
10+
/// The navigation was successfully executed.
11+
/// </summary>
12+
Succeeded,
13+
14+
/// <summary>
15+
/// The navigation was prevented.
16+
/// </summary>
17+
Prevented,
18+
19+
/// <summary>
20+
/// The OnBeforeInternalNavigation event handler threw an exception and the navigation did not complete.
21+
/// </summary>
22+
Faulted
23+
}
24+
#endif

0 commit comments

Comments
 (0)