Skip to content

feat: Navigation will set parameters #1584

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: v2
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions docs/site/docs/providing-input/passing-parameters-to-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
<p>Count: @InitialCount</p>
@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<ExampleComponent>();
var navigationManager = Services.GetRequiredService<NavigationManager>();
navigationManager.NavigateTo("/counter/123");
cut.Find("p").TextContent.ShouldBe("Count: 123");
}
}
```

## Further Reading

- <xref:inject-services>
15 changes: 13 additions & 2 deletions src/bunit/BunitContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ public partial class BunitContext : IDisposable, IAsyncDisposable
/// </summary>
public ComponentFactoryCollection ComponentFactories { get; } = new();

/// <summary>
/// TODO.
/// </summary>
internal ISet<IRenderedComponent<IComponent>> ReturnedRenderedComponents { get; } = new HashSet<IRenderedComponent<IComponent>>();

/// <summary>
/// Initializes a new instance of the <see cref="BunitContext"/> class.
/// </summary>
Expand Down Expand Up @@ -130,7 +135,11 @@ protected virtual void Dispose(bool disposing)
/// <summary>
/// Disposes all components rendered via this <see cref="BunitContext"/>.
/// </summary>
public Task DisposeComponentsAsync() => Renderer.DisposeComponents();
public Task DisposeComponentsAsync()
{
ReturnedRenderedComponents.Clear();
return Renderer.DisposeComponents();
}

/// <summary>
/// Instantiates and performs a first render of a component of type <typeparamref name="TComponent"/>.
Expand Down Expand Up @@ -205,7 +214,9 @@ private IRenderedComponent<TComponent> RenderInsideRenderTree<TComponent>(Render
where TComponent : IComponent
{
var baseResult = RenderInsideRenderTree(renderFragment);
return Renderer.FindComponent<TComponent>(baseResult);
var component = Renderer.FindComponent<TComponent>(baseResult);
ReturnedRenderedComponents.Add((IRenderedComponent<IComponent>)component);
return component;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public sealed class BunitNavigationManager : NavigationManager
{
private readonly BunitContext bunitContext;
private readonly Stack<NavigationHistory> history = new();
private readonly ComponentRouteParameterService componentRouteParameterService;

/// <summary>
/// The navigation history captured by the <see cref="BunitNavigationManager"/>.
Expand All @@ -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/");
}

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
namespace Bunit.TestDoubles;

using System.Globalization;
using System.Reflection;

internal sealed class ComponentRouteParameterService
{
private readonly BunitContext bunitContext;

/// <summary>
/// Initializes a new instance of the <see cref="ComponentRouteParameterService"/> class.
/// </summary>
public ComponentRouteParameterService(BunitContext bunitContext)
{
this.bunitContext = bunitContext;
}

/// <summary>
/// Triggers the components to update their parameters based on the route parameters.
/// </summary>
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<RouteAttribute>()
.ToArray();

private static Dictionary<string, object?> 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<string, object?>();

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);
}
}
Original file line number Diff line number Diff line change
@@ -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<NavigationLock>(5);
builder.AddAttribute(6, "OnBeforeInternalNavigation",
EventCallback.Factory.Create<LocationChangingContext>(this,
InterceptNavigation
));
builder.CloseComponent();
}

private static void InterceptNavigation(LocationChangingContext context)
{
context.PreventNavigation();
}
}
}
Loading