Skip to content

Commit 6ee39df

Browse files
committed
feat: Navigation will set parameters
1 parent 6032323 commit 6ee39df

File tree

7 files changed

+397
-2
lines changed

7 files changed

+397
-2
lines changed

CHANGELOG.md

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

77
## [Unreleased]
88

9+
### Added
10+
- Implemented feature to map route templates to parameters using NavigationManager. This allows parameters to be set based on the route template when navigating to a new location. Reported by [JamesNK](https://github.com/JamesNK) in [#1580](https://github.com/bUnit-dev/bUnit/issues/1580). By [@linkdotnet](https://github.com/linkdotnet).
11+
912
### Fixed
1013

1114
- Do not set the `Uri` or `BaseUri` property on the `FakeNavigationManager` if navigation is prevented by a handler on `net7.0` or greater. Reported and fixed by [@ayyron-dev](https://github.com/ayyron-dev) in [#1647](https://github.com/bUnit-dev/bUnit/issues/1647)

docs/site/docs/providing-input/passing-parameters-to-components.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,36 @@ A simple example of how to test a component that receives parameters from the qu
501501
}
502502
```
503503

504+
## Setting parameters via routing
505+
In Blazor, components can receive parameters via routing. This is particularly useful for passing data to components based on the URL. To enable this, the component parameters need to be annotated with the `[Parameter]` attribute and the `@page` directive (or `RouteAttribute` in code behind files).
506+
507+
An example component that receives parameters via routing:
508+
509+
```razor
510+
@page "/counter/{initialCount:int}"
511+
<p>Count: @InitialCount</p>
512+
@code {
513+
[Parameter]
514+
public int InitialCount { get; set; }
515+
}
516+
```
517+
518+
To test a component that receives parameters via routing, set the parameters using the `NavigationManager`:
519+
520+
```razor
521+
@inherits TestContext
522+
@code {
523+
[Fact]
524+
public void Component_receives_parameters_from_route()
525+
{
526+
var cut = Render<ExampleComponent>();
527+
var navigationManager = Services.GetRequiredService<NavigationManager>();
528+
navigationManager.NavigateTo("/counter/123");
529+
cut.Find("p").TextContent.ShouldBe("Count: 123");
530+
}
531+
}
532+
```
533+
504534
## Further Reading
505535

506536
- <xref:inject-services>

src/bunit/BunitContext.cs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ public partial class BunitContext : IDisposable, IAsyncDisposable
5353
/// </summary>
5454
public ComponentFactoryCollection ComponentFactories { get; } = new();
5555

56+
/// <summary>
57+
/// TODO.
58+
/// </summary>
59+
internal ISet<IRenderedComponent<IComponent>> ReturnedRenderedComponents { get; } = new HashSet<IRenderedComponent<IComponent>>();
60+
5661
/// <summary>
5762
/// Initializes a new instance of the <see cref="BunitContext"/> class.
5863
/// </summary>
@@ -130,7 +135,11 @@ protected virtual void Dispose(bool disposing)
130135
/// <summary>
131136
/// Disposes all components rendered via this <see cref="BunitContext"/>.
132137
/// </summary>
133-
public Task DisposeComponentsAsync() => Renderer.DisposeComponents();
138+
public Task DisposeComponentsAsync()
139+
{
140+
ReturnedRenderedComponents.Clear();
141+
return Renderer.DisposeComponents();
142+
}
134143

135144
/// <summary>
136145
/// Instantiates and performs a first render of a component of type <typeparamref name="TComponent"/>.
@@ -205,7 +214,9 @@ private IRenderedComponent<TComponent> RenderInsideRenderTree<TComponent>(Render
205214
where TComponent : IComponent
206215
{
207216
var baseResult = RenderInsideRenderTree(renderFragment);
208-
return Renderer.FindComponent<TComponent>(baseResult);
217+
var component = Renderer.FindComponent<TComponent>(baseResult);
218+
ReturnedRenderedComponents.Add((IRenderedComponent<IComponent>)component);
219+
return component;
209220
}
210221

211222
/// <summary>

src/bunit/TestDoubles/NavigationManager/BunitNavigationManager.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public sealed class BunitNavigationManager : NavigationManager
1414
{
1515
private readonly BunitContext bunitContext;
1616
private readonly Stack<NavigationHistory> history = new();
17+
private readonly ComponentRouteParameterService componentRouteParameterService;
1718

1819
/// <summary>
1920
/// The navigation history captured by the <see cref="BunitNavigationManager"/>.
@@ -31,6 +32,7 @@ public sealed class BunitNavigationManager : NavigationManager
3132
public BunitNavigationManager(BunitContext bunitContext)
3233
{
3334
this.bunitContext = bunitContext;
35+
componentRouteParameterService = new ComponentRouteParameterService(bunitContext);
3436
Initialize("http://localhost/", "http://localhost/");
3537
}
3638

@@ -71,6 +73,7 @@ protected override void NavigateToCore(string uri, NavigationOptions options)
7173
}
7274

7375
Uri = absoluteUri.OriginalString;
76+
componentRouteParameterService.UpdateComponentsWithRouteParameters(absoluteUri);
7477

7578
// Only notify of changes if user navigates within the same
7679
// base url (domain). Otherwise, the user navigated away
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
namespace Bunit.TestDoubles;
2+
3+
using System.Globalization;
4+
using System.Reflection;
5+
6+
internal sealed class ComponentRouteParameterService
7+
{
8+
private readonly BunitContext bunitContext;
9+
10+
/// <summary>
11+
/// Initializes a new instance of the <see cref="ComponentRouteParameterService"/> class.
12+
/// </summary>
13+
public ComponentRouteParameterService(BunitContext bunitContext)
14+
{
15+
this.bunitContext = bunitContext;
16+
}
17+
18+
/// <summary>
19+
/// Triggers the components to update their parameters based on the route parameters.
20+
/// </summary>
21+
public void UpdateComponentsWithRouteParameters(Uri uri)
22+
{
23+
_ = uri ?? throw new ArgumentNullException(nameof(uri));
24+
25+
var relativeUri = uri.PathAndQuery;
26+
27+
foreach (var renderedComponent in bunitContext.ReturnedRenderedComponents)
28+
{
29+
var instance = renderedComponent.Instance;
30+
var routeAttributes = GetRouteAttributesFromComponent(instance);
31+
32+
if (routeAttributes.Length == 0)
33+
{
34+
continue;
35+
}
36+
37+
foreach (var template in routeAttributes.Select(r => r.Template))
38+
{
39+
var parameters = GetParametersFromTemplateAndUri(template, relativeUri, instance);
40+
if (parameters.Count > 0)
41+
{
42+
bunitContext.Renderer.SetDirectParametersAsync(renderedComponent, ParameterView.FromDictionary(parameters));
43+
}
44+
}
45+
}
46+
}
47+
48+
private static RouteAttribute[] GetRouteAttributesFromComponent(IComponent instance) =>
49+
instance.GetType()
50+
.GetCustomAttributes(typeof(RouteAttribute), true)
51+
.Cast<RouteAttribute>()
52+
.ToArray();
53+
54+
private static Dictionary<string, object?> GetParametersFromTemplateAndUri(string template, string relativeUri, IComponent instance)
55+
{
56+
var templateSegments = template.Trim('/').Split("/");
57+
var uriSegments = relativeUri.Trim('/').Split("/");
58+
59+
if (templateSegments.Length > uriSegments.Length)
60+
{
61+
return [];
62+
}
63+
64+
var parameters = new Dictionary<string, object?>();
65+
66+
for (var i = 0; i < templateSegments.Length; i++)
67+
{
68+
var templateSegment = templateSegments[i];
69+
if (templateSegment.StartsWith('{') && templateSegment.EndsWith('}'))
70+
{
71+
var parameterName = GetParameterName(templateSegment);
72+
var property = GetParameterProperty(instance, parameterName);
73+
74+
if (property is null)
75+
{
76+
continue;
77+
}
78+
79+
var isCatchAllParameter = templateSegment[1] == '*';
80+
parameters[property.Name] = isCatchAllParameter
81+
? string.Join("/", uriSegments.Skip(i))
82+
: GetValue(uriSegments[i], property);
83+
}
84+
else if (templateSegment != uriSegments[i])
85+
{
86+
return [];
87+
}
88+
}
89+
90+
return parameters;
91+
}
92+
93+
private static string GetParameterName(string templateSegment) =>
94+
templateSegment
95+
.Trim('{', '}', '*')
96+
.Replace("?", string.Empty, StringComparison.OrdinalIgnoreCase)
97+
.Split(':')[0];
98+
99+
private static PropertyInfo? GetParameterProperty(object instance, string propertyName)
100+
{
101+
var propertyInfos = instance.GetType()
102+
.GetProperties(BindingFlags.Public | BindingFlags.Instance);
103+
104+
return Array.Find(propertyInfos, prop => prop.GetCustomAttributes(typeof(ParameterAttribute), true).Length > 0 &&
105+
string.Equals(prop.Name, propertyName, StringComparison.OrdinalIgnoreCase));
106+
}
107+
108+
private static object GetValue(string value, PropertyInfo property)
109+
{
110+
var underlyingType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType;
111+
return Convert.ChangeType(value, underlyingType, CultureInfo.InvariantCulture);
112+
}
113+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
namespace Bunit.TestDoubles;
2+
3+
public partial class RouterTests
4+
{
5+
[Route("/page/{count:int}/{name}")]
6+
private sealed class ComponentWithPageAttribute : ComponentBase
7+
{
8+
[Parameter] public int Count { get; set; }
9+
[Parameter] public string Name { get; set; }
10+
protected override void BuildRenderTree(RenderTreeBuilder builder)
11+
{
12+
builder.OpenElement(0, "p");
13+
builder.AddContent(1, Count);
14+
builder.AddContent(2, " / ");
15+
builder.AddContent(3, Name);
16+
builder.CloseElement();
17+
}
18+
}
19+
20+
[Route("/page")]
21+
[Route("/page/{count:int}")]
22+
private sealed class ComponentWithMultiplePageAttributes : ComponentBase
23+
{
24+
[Parameter] public int Count { get; set; }
25+
protected override void BuildRenderTree(RenderTreeBuilder builder)
26+
{
27+
builder.OpenElement(0, "p");
28+
builder.AddContent(1, Count);
29+
builder.CloseElement();
30+
}
31+
}
32+
33+
[Route("/page/{count:int}")]
34+
private sealed class ComponentWithOtherParameters : ComponentBase
35+
{
36+
[Parameter] public int Count { get; set; }
37+
[Parameter] public int OtherNumber { get; set; }
38+
39+
protected override void BuildRenderTree(RenderTreeBuilder builder)
40+
{
41+
builder.OpenElement(0, "p");
42+
builder.AddContent(1, Count);
43+
builder.AddContent(2, "/");
44+
builder.AddContent(3, OtherNumber);
45+
builder.CloseElement();
46+
}
47+
}
48+
49+
[Route("/page/{*pageRoute}")]
50+
private sealed class ComponentWithCatchAllRoute : ComponentBase
51+
{
52+
[Parameter] public string PageRoute { get; set; }
53+
54+
protected override void BuildRenderTree(RenderTreeBuilder builder)
55+
{
56+
builder.OpenElement(0, "p");
57+
builder.AddContent(1, PageRoute);
58+
builder.CloseElement();
59+
}
60+
}
61+
62+
[Route("/page/{count:int}")]
63+
private sealed class ComponentWithCustomOnParametersSetAsyncsCall : ComponentBase
64+
{
65+
[Parameter] public int Count { get; set; }
66+
[Parameter] public int IncrementOnParametersSet { get; set; }
67+
68+
protected override void OnParametersSet()
69+
{
70+
Count += IncrementOnParametersSet;
71+
}
72+
73+
protected override void BuildRenderTree(RenderTreeBuilder builder)
74+
{
75+
builder.OpenElement(0, "p");
76+
builder.AddContent(1, Count);
77+
builder.CloseElement();
78+
}
79+
}
80+
81+
[Route("/page/{count?:int}")]
82+
private sealed class ComponentWithOptionalParameter : ComponentBase
83+
{
84+
[Parameter] public int? Count { get; set; }
85+
86+
protected override void BuildRenderTree(RenderTreeBuilder builder)
87+
{
88+
builder.OpenElement(0, "p");
89+
builder.AddContent(1, Count);
90+
builder.CloseElement();
91+
}
92+
}
93+
94+
[Route("/page/{count:int}")]
95+
private sealed class ComponentThatNavigatesToSelfOnButtonClick : ComponentBase
96+
{
97+
[Parameter] public int Count { get; set; }
98+
99+
[Inject] private NavigationManager NavigationManager { get; set; }
100+
101+
protected override void BuildRenderTree(RenderTreeBuilder builder)
102+
{
103+
builder.OpenElement(0, "button");
104+
builder.AddAttribute(1, "onclick", EventCallback.Factory.Create(this, () => NavigationManager.NavigateTo($"/page/{Count + 1}")));
105+
builder.AddContent(2, "Increment");
106+
builder.CloseElement();
107+
builder.OpenElement(3, "p");
108+
builder.AddContent(4, Count);
109+
builder.CloseElement();
110+
}
111+
}
112+
113+
[Route("/page/{count:int}")]
114+
private sealed class ComponentThatNavigatesToSelfOnButtonClickIntercepted : ComponentBase
115+
{
116+
[Parameter] public int Count { get; set; }
117+
118+
[Inject] private NavigationManager NavigationManager { get; set; }
119+
120+
protected override void BuildRenderTree(RenderTreeBuilder builder)
121+
{
122+
builder.OpenElement(0, "button");
123+
builder.AddAttribute(1, "onclick", EventCallback.Factory.Create(this, () => NavigationManager.NavigateTo($"/page/{Count + 1}")));
124+
builder.AddContent(2, "Increment");
125+
builder.CloseElement();
126+
builder.OpenElement(3, "p");
127+
builder.AddContent(4, Count);
128+
builder.CloseElement();
129+
builder.OpenComponent<NavigationLock>(5);
130+
builder.AddAttribute(6, "OnBeforeInternalNavigation",
131+
EventCallback.Factory.Create<LocationChangingContext>(this,
132+
InterceptNavigation
133+
));
134+
builder.CloseComponent();
135+
}
136+
137+
private static void InterceptNavigation(LocationChangingContext context)
138+
{
139+
context.PreventNavigation();
140+
}
141+
}
142+
}

0 commit comments

Comments
 (0)