diff --git a/src/Components/Components/src/NavigationManager.cs b/src/Components/Components/src/NavigationManager.cs index 260c38496c15..bd0148871f6c 100644 --- a/src/Components/Components/src/NavigationManager.cs +++ b/src/Components/Components/src/NavigationManager.cs @@ -2,7 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Buffers; +using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Text; using Microsoft.AspNetCore.Components.Routing; namespace Microsoft.AspNetCore.Components; @@ -206,14 +210,14 @@ internal string ResolveRelativeToCurrentPath(string relativeUri) var queryOrFragmentIndex = currentUri.IndexOfAny('?', '#'); var pathOnlyLength = queryOrFragmentIndex >= 0 ? queryOrFragmentIndex : currentUri.Length; var lastSlashIndex = currentUri[..pathOnlyLength].LastIndexOf('/'); - + if (lastSlashIndex < 0) { // No slash found - this shouldn't happen for valid absolute URIs // In this edge case, just append to the current URI return string.Concat(_uri, relativeUri); } - + // Keep everything up to and including the last slash, then append the relative URI var basePathLength = lastSlashIndex + 1; return string.Concat(currentUri[..basePathLength], relativeUri.AsSpan()); @@ -640,6 +644,125 @@ private static bool TryGetLengthOfBaseUriPrefix(Uri baseUri, string uri, out int return false; } + // Cache for component route templates to avoid repeated reflection + private static readonly ConcurrentDictionary _componentRouteTemplates = new(); + + /// + /// Gets the URI for the specified component type with optional route parameters. + /// + /// The component type to navigate to. + /// The route parameters to include in the URI. + /// The URI for the specified component. + /// Thrown when the component type does not have a route attribute. + public string GetUri(params object[] parameters) + { + var componentType = typeof(TComponent); + var routeTemplates = GetComponentRouteTemplates(componentType); + + if (routeTemplates.Length == 0) + { + throw new InvalidOperationException($"Component '{componentType.FullName}' does not have a Route attribute."); + } + + // For now, use the first route template (future enhancement: allow specifying which route to use) + var routeTemplate = routeTemplates[0]; + return BindParametersToRouteTemplate(routeTemplate, parameters); + } + + /// + /// Navigates to the specified component type with optional route parameters. + /// + /// The component type to navigate to. + /// The route parameters to include in the URI. + /// Thrown when the component type does not have a route attribute. + public void NavigateTo(params object[] parameters) + { + var uri = GetUri(parameters); + NavigateTo(uri); + } + + /// + /// Gets the route templates for the specified component type. + /// + /// The component type. + /// An array of route templates. + private static string[] GetComponentRouteTemplates(Type componentType) + { + return _componentRouteTemplates.GetOrAdd(componentType, type => + { + var routeAttributes = type.GetCustomAttributes(inherit: false); + var routeAttributesArray = routeAttributes as RouteAttribute[] ?? routeAttributes.ToArray(); + if (routeAttributesArray.Length == 0) + { + return Array.Empty(); + } + + var templates = new string[routeAttributesArray.Length]; + for (var i = 0; i < routeAttributesArray.Length; i++) + { + templates[i] = routeAttributesArray[i].Template; + } + + return templates; + }); + } + + /// + /// Binds parameters to a route template. + /// + /// The route template. + /// The parameters to bind. + /// The route template with parameters bound. + private static string BindParametersToRouteTemplate(string routeTemplate, object[] parameters) + { + if (parameters == null || parameters.Length == 0) + { + return routeTemplate; + } + + var sb = new StringBuilder(capacity: routeTemplate.Length + parameters.Length * 8); + var pos = 0; + var paramIndex = 0; + + while (pos < routeTemplate.Length) + { + var open = routeTemplate.IndexOf('{', pos); + if (open < 0) + { + // No more placeholders + sb.Append(routeTemplate.AsSpan(pos)); + break; + } + + // Append literal text before the placeholder + sb.Append(routeTemplate.AsSpan(pos, open - pos)); + + var close = routeTemplate.IndexOf('}', open + 1); + if (close < 0) + { + // Malformed template; append rest and break + sb.Append(routeTemplate.AsSpan(open)); + break; + } + + // If no more parameter values provided, leave placeholder as-is + if (paramIndex >= parameters.Length) + { + sb.Append(routeTemplate.AsSpan(open, close - open + 1)); + } + else + { + var parameterValue = parameters[paramIndex]?.ToString() ?? string.Empty; + sb.Append(parameterValue); + paramIndex++; + } + + pos = close + 1; + } + + return sb.ToString(); + } + private static void Validate(Uri? baseUri, string uri) { if (baseUri == null || uri == null) diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 1860c583402f..bcf854a9677d 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -5,6 +5,8 @@ Microsoft.AspNetCore.Components.CascadingParameterSubscription Microsoft.AspNetCore.Components.CascadingParameterSubscription.CascadingParameterSubscription() -> void Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder.SetAttributeValue(int frameIndex, object? value) -> void static Microsoft.AspNetCore.Components.NavigationManagerExtensions.GetUriWithHash(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! hash) -> string! +Microsoft.AspNetCore.Components.NavigationManager.GetUri(params object![]! parameters) -> string! +Microsoft.AspNetCore.Components.NavigationManager.NavigateTo(params object![]! parameters) -> void Microsoft.AspNetCore.Components.NavigationOptions.RelativeToCurrentUri.get -> bool Microsoft.AspNetCore.Components.NavigationOptions.RelativeToCurrentUri.init -> void Microsoft.AspNetCore.Components.IComponentPropertyActivator diff --git a/src/Components/Components/test/NavigationManagerTest.cs b/src/Components/Components/test/NavigationManagerTest.cs index 99c17847dec6..1fabc70fd61a 100644 --- a/src/Components/Components/test/NavigationManagerTest.cs +++ b/src/Components/Components/test/NavigationManagerTest.cs @@ -1109,6 +1109,98 @@ public void ResolveRelativeToCurrentPath_ThrowsForNullUri() testNavManager.ResolveRelativeToCurrentPath(null!)); } + // Type-safe navigation tests (valid when type-safe APIs from the PR are present) + [Fact] + public void GetUri_Type_NoParameters_ReturnsExpectedAbsoluteUri() + { + var nav = new TestNavigationManager("http://example.com/"); + + var uri = nav.GetUri(); + + Assert.Equal("/list", uri); + } + + [Fact] + public void GetUri_Type_SingleParameter_ReturnsExpectedAbsoluteUri() + { + var nav = new TestNavigationManager("http://example.com/"); + + var uri = nav.GetUri("123"); + + Assert.Equal("/user/123", uri); + } + + [Fact] + public void GetUri_Type_MultipleParameters_ReturnsExpectedAbsoluteUri() + { + var nav = new TestNavigationManager("http://example.com/"); + + var uri = nav.GetUri("p1", "books"); + + Assert.Equal("/product/p1/books", uri); + } + + [Fact] + public void NavigateTo_Generic_InvokesNavigateToCore_WithResolvedUri() + { + var nav = new TestNavigationManagerWithNavigationTracking("http://example.com/"); + + nav.NavigateTo(); + + Assert.Single(nav.Navigations); + Assert.Equal("/list", nav.Navigations[0].uri); + } + + [Fact] + public void NavigateTo_Generic_WithParameters_InvokesNavigateToCore_WithResolvedUri() + { + var nav = new TestNavigationManagerWithNavigationTracking("http://example.com/"); + + nav.NavigateTo("123"); + + Assert.Single(nav.Navigations); + Assert.Equal("/user/123", nav.Navigations[0].uri); + } + + [Fact] + public void GetUri_WithoutRoute_ThrowsInvalidOperationException() + { + var nav = new TestNavigationManager("http://example.com/"); + + var ex = Assert.Throws(() => nav.GetUri()); + + Assert.Contains("route", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + // Sample component types used for type-safe navigation tests + [Route("/list")] + private class ListSampleComponent : IComponent + { + public void Attach(RenderHandle renderHandle) { } + public Task SetParametersAsync(ParameterView parameters) => Task.CompletedTask; + } + + [Route("/user/{userId}")] + private class UserSampleComponent : IComponent + { + public void Attach(RenderHandle renderHandle) { } + public Task SetParametersAsync(ParameterView parameters) => Task.CompletedTask; + } + + [Route("/product/{productId}/{category}")] + private class ProductSampleComponent : IComponent + { + public void Attach(RenderHandle renderHandle) { } + public Task SetParametersAsync(ParameterView parameters) => Task.CompletedTask; + } + + // Component with no route attribute to assert negative behavior + private class NoRouteComponent : IComponent + { + public void Attach(RenderHandle renderHandle) { } + public Task SetParametersAsync(ParameterView parameters) => Task.CompletedTask; + } + private class TestNavigationManager : NavigationManager { public TestNavigationManager() @@ -1130,7 +1222,7 @@ public async Task RunNotifyLocationChangingAsync(string uri, string state, protected override void NavigateToCore(string uri, bool forceLoad) { - throw new System.NotImplementedException(); + base.NavigateToCore(uri, forceLoad); } protected override void SetNavigationLockState(bool value)