diff --git a/CHANGELOG.md b/CHANGELOG.md
index f0745dfbc..775a89e03 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,9 @@ All notable changes to **bUnit** will be documented in this file. The project ad
## [Unreleased]
+### Added
+- 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).
+
### Fixed
- 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)
diff --git a/docs/site/docs/providing-input/passing-parameters-to-components.md b/docs/site/docs/providing-input/passing-parameters-to-components.md
index 69bd06b7a..4b77e3df4 100644
--- a/docs/site/docs/providing-input/passing-parameters-to-components.md
+++ b/docs/site/docs/providing-input/passing-parameters-to-components.md
@@ -501,6 +501,36 @@ A simple example of how to test a component that receives parameters from the qu
}
```
+## Setting parameters via routing
+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).
+
+An example component that receives parameters via routing:
+
+```razor
+@page "/counter/{initialCount:int}"
+
Count: @InitialCount
+@code {
+ [Parameter]
+ public int InitialCount { get; set; }
+}
+```
+
+To test a component that receives parameters via routing, set the parameters using the `NavigationManager`:
+
+```razor
+@inherits TestContext
+@code {
+ [Fact]
+ public void Component_receives_parameters_from_route()
+ {
+ var cut = Render();
+ var navigationManager = Services.GetRequiredService();
+ navigationManager.NavigateTo("/counter/123");
+ cut.Find("p").TextContent.ShouldBe("Count: 123");
+ }
+}
+```
+
## Further Reading
-
diff --git a/src/bunit/BunitContext.cs b/src/bunit/BunitContext.cs
index 31d7a28c1..1464960c1 100644
--- a/src/bunit/BunitContext.cs
+++ b/src/bunit/BunitContext.cs
@@ -53,6 +53,11 @@ public partial class BunitContext : IDisposable, IAsyncDisposable
///
public ComponentFactoryCollection ComponentFactories { get; } = new();
+ ///
+ /// TODO.
+ ///
+ internal ISet> ReturnedRenderedComponents { get; } = new HashSet>();
+
///
/// Initializes a new instance of the class.
///
@@ -130,7 +135,11 @@ protected virtual void Dispose(bool disposing)
///
/// Disposes all components rendered via this .
///
- public Task DisposeComponentsAsync() => Renderer.DisposeComponents();
+ public Task DisposeComponentsAsync()
+ {
+ ReturnedRenderedComponents.Clear();
+ return Renderer.DisposeComponents();
+ }
///
/// Instantiates and performs a first render of a component of type .
@@ -205,7 +214,9 @@ private IRenderedComponent RenderInsideRenderTree(Render
where TComponent : IComponent
{
var baseResult = RenderInsideRenderTree(renderFragment);
- return Renderer.FindComponent(baseResult);
+ var component = Renderer.FindComponent(baseResult);
+ ReturnedRenderedComponents.Add((IRenderedComponent)component);
+ return component;
}
///
diff --git a/src/bunit/TestDoubles/NavigationManager/BunitNavigationManager.cs b/src/bunit/TestDoubles/NavigationManager/BunitNavigationManager.cs
index cdac00cfd..0687656f0 100644
--- a/src/bunit/TestDoubles/NavigationManager/BunitNavigationManager.cs
+++ b/src/bunit/TestDoubles/NavigationManager/BunitNavigationManager.cs
@@ -14,6 +14,7 @@ public sealed class BunitNavigationManager : NavigationManager
{
private readonly BunitContext bunitContext;
private readonly Stack history = new();
+ private readonly ComponentRouteParameterService componentRouteParameterService;
///
/// The navigation history captured by the .
@@ -31,6 +32,7 @@ public sealed class BunitNavigationManager : NavigationManager
public BunitNavigationManager(BunitContext bunitContext)
{
this.bunitContext = bunitContext;
+ componentRouteParameterService = new ComponentRouteParameterService(bunitContext);
Initialize("http://localhost/", "http://localhost/");
}
@@ -71,6 +73,7 @@ protected override void NavigateToCore(string uri, NavigationOptions options)
}
Uri = absoluteUri.OriginalString;
+ componentRouteParameterService.UpdateComponentsWithRouteParameters(absoluteUri);
// Only notify of changes if user navigates within the same
// base url (domain). Otherwise, the user navigated away
diff --git a/src/bunit/TestDoubles/NavigationManager/ComponentRouteParameterService.cs b/src/bunit/TestDoubles/NavigationManager/ComponentRouteParameterService.cs
new file mode 100644
index 000000000..b4b56cb81
--- /dev/null
+++ b/src/bunit/TestDoubles/NavigationManager/ComponentRouteParameterService.cs
@@ -0,0 +1,113 @@
+namespace Bunit.TestDoubles;
+
+using System.Globalization;
+using System.Reflection;
+
+internal sealed class ComponentRouteParameterService
+{
+ private readonly BunitContext bunitContext;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ComponentRouteParameterService(BunitContext bunitContext)
+ {
+ this.bunitContext = bunitContext;
+ }
+
+ ///
+ /// Triggers the components to update their parameters based on the route parameters.
+ ///
+ public void UpdateComponentsWithRouteParameters(Uri uri)
+ {
+ _ = uri ?? throw new ArgumentNullException(nameof(uri));
+
+ var relativeUri = uri.PathAndQuery;
+
+ foreach (var renderedComponent in bunitContext.ReturnedRenderedComponents)
+ {
+ var instance = renderedComponent.Instance;
+ var routeAttributes = GetRouteAttributesFromComponent(instance);
+
+ if (routeAttributes.Length == 0)
+ {
+ continue;
+ }
+
+ foreach (var template in routeAttributes.Select(r => r.Template))
+ {
+ var parameters = GetParametersFromTemplateAndUri(template, relativeUri, instance);
+ if (parameters.Count > 0)
+ {
+ bunitContext.Renderer.SetDirectParametersAsync(renderedComponent, ParameterView.FromDictionary(parameters));
+ }
+ }
+ }
+ }
+
+ private static RouteAttribute[] GetRouteAttributesFromComponent(IComponent instance) =>
+ instance.GetType()
+ .GetCustomAttributes(typeof(RouteAttribute), true)
+ .Cast()
+ .ToArray();
+
+ private static Dictionary GetParametersFromTemplateAndUri(string template, string relativeUri, IComponent instance)
+ {
+ var templateSegments = template.Trim('/').Split("/");
+ var uriSegments = relativeUri.Trim('/').Split("/");
+
+ if (templateSegments.Length > uriSegments.Length)
+ {
+ return [];
+ }
+
+ var parameters = new Dictionary();
+
+ for (var i = 0; i < templateSegments.Length; i++)
+ {
+ var templateSegment = templateSegments[i];
+ if (templateSegment.StartsWith('{') && templateSegment.EndsWith('}'))
+ {
+ var parameterName = GetParameterName(templateSegment);
+ var property = GetParameterProperty(instance, parameterName);
+
+ if (property is null)
+ {
+ continue;
+ }
+
+ var isCatchAllParameter = templateSegment[1] == '*';
+ parameters[property.Name] = isCatchAllParameter
+ ? string.Join("/", uriSegments.Skip(i))
+ : GetValue(uriSegments[i], property);
+ }
+ else if (templateSegment != uriSegments[i])
+ {
+ return [];
+ }
+ }
+
+ return parameters;
+ }
+
+ private static string GetParameterName(string templateSegment) =>
+ templateSegment
+ .Trim('{', '}', '*')
+ .Replace("?", string.Empty, StringComparison.OrdinalIgnoreCase)
+ .Split(':')[0];
+
+ private static PropertyInfo? GetParameterProperty(object instance, string propertyName)
+ {
+ var propertyInfos = instance.GetType()
+ .GetProperties(BindingFlags.Public | BindingFlags.Instance);
+
+ return Array.Find(propertyInfos, prop => prop.GetCustomAttributes(typeof(ParameterAttribute), true).Length > 0 &&
+ string.Equals(prop.Name, propertyName, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static object GetValue(string value, PropertyInfo property)
+ {
+ var underlyingType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType;
+ return Convert.ChangeType(value, underlyingType, CultureInfo.InvariantCulture);
+ }
+}
diff --git a/tests/bunit.tests/TestDoubles/NavigationManager/RouterTests.Components.cs b/tests/bunit.tests/TestDoubles/NavigationManager/RouterTests.Components.cs
new file mode 100644
index 000000000..eec5db773
--- /dev/null
+++ b/tests/bunit.tests/TestDoubles/NavigationManager/RouterTests.Components.cs
@@ -0,0 +1,142 @@
+namespace Bunit.TestDoubles;
+
+public partial class RouterTests
+{
+ [Route("/page/{count:int}/{name}")]
+ private sealed class ComponentWithPageAttribute : ComponentBase
+ {
+ [Parameter] public int Count { get; set; }
+ [Parameter] public string Name { get; set; }
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ builder.OpenElement(0, "p");
+ builder.AddContent(1, Count);
+ builder.AddContent(2, " / ");
+ builder.AddContent(3, Name);
+ builder.CloseElement();
+ }
+ }
+
+ [Route("/page")]
+ [Route("/page/{count:int}")]
+ private sealed class ComponentWithMultiplePageAttributes : ComponentBase
+ {
+ [Parameter] public int Count { get; set; }
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ builder.OpenElement(0, "p");
+ builder.AddContent(1, Count);
+ builder.CloseElement();
+ }
+ }
+
+ [Route("/page/{count:int}")]
+ private sealed class ComponentWithOtherParameters : ComponentBase
+ {
+ [Parameter] public int Count { get; set; }
+ [Parameter] public int OtherNumber { get; set; }
+
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ builder.OpenElement(0, "p");
+ builder.AddContent(1, Count);
+ builder.AddContent(2, "/");
+ builder.AddContent(3, OtherNumber);
+ builder.CloseElement();
+ }
+ }
+
+ [Route("/page/{*pageRoute}")]
+ private sealed class ComponentWithCatchAllRoute : ComponentBase
+ {
+ [Parameter] public string PageRoute { get; set; }
+
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ builder.OpenElement(0, "p");
+ builder.AddContent(1, PageRoute);
+ builder.CloseElement();
+ }
+ }
+
+ [Route("/page/{count:int}")]
+ private sealed class ComponentWithCustomOnParametersSetAsyncsCall : ComponentBase
+ {
+ [Parameter] public int Count { get; set; }
+ [Parameter] public int IncrementOnParametersSet { get; set; }
+
+ protected override void OnParametersSet()
+ {
+ Count += IncrementOnParametersSet;
+ }
+
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ builder.OpenElement(0, "p");
+ builder.AddContent(1, Count);
+ builder.CloseElement();
+ }
+ }
+
+ [Route("/page/{count?:int}")]
+ private sealed class ComponentWithOptionalParameter : ComponentBase
+ {
+ [Parameter] public int? Count { get; set; }
+
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ builder.OpenElement(0, "p");
+ builder.AddContent(1, Count);
+ builder.CloseElement();
+ }
+ }
+
+ [Route("/page/{count:int}")]
+ private sealed class ComponentThatNavigatesToSelfOnButtonClick : ComponentBase
+ {
+ [Parameter] public int Count { get; set; }
+
+ [Inject] private NavigationManager NavigationManager { get; set; }
+
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ builder.OpenElement(0, "button");
+ builder.AddAttribute(1, "onclick", EventCallback.Factory.Create(this, () => NavigationManager.NavigateTo($"/page/{Count + 1}")));
+ builder.AddContent(2, "Increment");
+ builder.CloseElement();
+ builder.OpenElement(3, "p");
+ builder.AddContent(4, Count);
+ builder.CloseElement();
+ }
+ }
+
+ [Route("/page/{count:int}")]
+ private sealed class ComponentThatNavigatesToSelfOnButtonClickIntercepted : ComponentBase
+ {
+ [Parameter] public int Count { get; set; }
+
+ [Inject] private NavigationManager NavigationManager { get; set; }
+
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ builder.OpenElement(0, "button");
+ builder.AddAttribute(1, "onclick", EventCallback.Factory.Create(this, () => NavigationManager.NavigateTo($"/page/{Count + 1}")));
+ builder.AddContent(2, "Increment");
+ builder.CloseElement();
+ builder.OpenElement(3, "p");
+ builder.AddContent(4, Count);
+ builder.CloseElement();
+ builder.OpenComponent(5);
+ builder.AddAttribute(6, "OnBeforeInternalNavigation",
+ EventCallback.Factory.Create(this,
+ InterceptNavigation
+ ));
+ builder.CloseComponent();
+ }
+
+ private static void InterceptNavigation(LocationChangingContext context)
+ {
+ context.PreventNavigation();
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/bunit.tests/TestDoubles/NavigationManager/RouterTests.cs b/tests/bunit.tests/TestDoubles/NavigationManager/RouterTests.cs
new file mode 100644
index 000000000..0f81e447b
--- /dev/null
+++ b/tests/bunit.tests/TestDoubles/NavigationManager/RouterTests.cs
@@ -0,0 +1,93 @@
+namespace Bunit.TestDoubles;
+
+public partial class RouterTests : BunitContext
+{
+ [Fact]
+ public void NavigatingToRouteWithPageAttributeShouldSetParameters()
+ {
+ var cut = Render();
+ var navigationManager = cut.Services.GetRequiredService();
+
+ navigationManager.NavigateTo("/page/1/test");
+
+ cut.Find("p").TextContent.ShouldBe("1 / test");
+ }
+
+ [Fact]
+ public void ShouldParseMultiplePageAttributes()
+ {
+ var cut = Render();
+ var navigationManager = cut.Services.GetRequiredService();
+
+ navigationManager.NavigateTo("/page/1");
+
+ cut.Find("p").TextContent.ShouldBe("1");
+ }
+
+ [Fact]
+ public void WhenParameterIsSetNavigatingShouldNotResetNonPageAttributeParameters()
+ {
+ var cut = Render(p => p.Add(ps => ps.OtherNumber, 2));
+ var navigationManager = cut.Services.GetRequiredService();
+
+ navigationManager.NavigateTo("/page/1");
+
+ cut.Find("p").TextContent.ShouldBe("1/2");
+ }
+
+ [Fact]
+ public void GivenACatchAllRouteShouldSetParameter()
+ {
+ var cut = Render();
+ var navigationManager = cut.Services.GetRequiredService();
+
+ navigationManager.NavigateTo("/page/1/2/3");
+
+ cut.Find("p").TextContent.ShouldBe("1/2/3");
+ }
+
+ [Fact]
+ public void ShouldInvokeParameterLifeCycleEvents()
+ {
+ var cut = Render(
+ p => p.Add(ps => ps.IncrementOnParametersSet, 10));
+ var navigationManager = cut.Services.GetRequiredService();
+
+ navigationManager.NavigateTo("/page/1");
+
+ cut.Find("p").TextContent.ShouldBe("11");
+ }
+
+ [Theory]
+ [InlineData("/page/1", "1")]
+ [InlineData("/page", "")]
+ public void ShouldSetOptionalParameter(string uri, string expectedTextContent)
+ {
+ var cut = Render();
+ var navigationManager = cut.Services.GetRequiredService();
+
+ navigationManager.NavigateTo(uri);
+
+ cut.Find("p").TextContent.ShouldBe(expectedTextContent);
+ }
+
+ [Fact]
+ public void ComponentThatNavigatesToSelfOnClickShouldBeUpdated()
+ {
+ var cut = Render();
+
+ cut.Find("button").Click();
+
+ cut.Find("p").TextContent.ShouldBe("1");
+ }
+
+ [Fact]
+ public void ComponentThatInterceptsNavigationShouldNotBeUpdated()
+ {
+ var cut = Render();
+
+ cut.Find("button").Click();
+
+ cut.Find("p").TextContent.ShouldBe("0");
+ }
+}
\ No newline at end of file