From e1ac435eadffdb1309e758ad3a4674492f5f7838 Mon Sep 17 00:00:00 2001 From: Oleksandr Liakhevych Date: Fri, 31 Mar 2023 00:31:25 +0300 Subject: [PATCH 1/2] Use Router for URI navigation --- samples/ControlGallery/AppShell.razor | 2 +- .../Navigation/ShellNavigationPage.razor | 43 ------- .../ShellNavigationTargetPage.razor | 49 ------- .../Views/Navigation/UriNavigationPage.razor | 45 +++++++ .../Navigation/UriNavigationTargetPage.razor | 49 +++++++ .../MauiAppBuilderExtensions.cs | 4 + .../MauiBlazorBindingsRenderer.cs | 9 ++ .../Navigation/Navigation.Uri.cs | 121 +++++++++++------- .../Navigation/Shell/StructuredRoute.cs | 99 -------------- .../Navigation/Uri/MbbNavigationManager.cs | 15 +++ .../Navigation/Uri/NavigationInterception.cs | 8 ++ .../Components/PageWithUrl.razor | 78 +++++++---- ...ationTests.cs => NonUriNavigationTests.cs} | 4 +- .../Navigation/StructuredRouteTest.cs | 73 ----------- ...vigationTests.cs => UriNavigationTests.cs} | 61 ++++++++- 15 files changed, 315 insertions(+), 345 deletions(-) delete mode 100644 samples/ControlGallery/Views/Navigation/ShellNavigationPage.razor delete mode 100644 samples/ControlGallery/Views/Navigation/ShellNavigationTargetPage.razor create mode 100644 samples/ControlGallery/Views/Navigation/UriNavigationPage.razor create mode 100644 samples/ControlGallery/Views/Navigation/UriNavigationTargetPage.razor delete mode 100644 src/BlazorBindings.Maui/Navigation/Shell/StructuredRoute.cs create mode 100644 src/BlazorBindings.Maui/Navigation/Uri/MbbNavigationManager.cs create mode 100644 src/BlazorBindings.Maui/Navigation/Uri/NavigationInterception.cs rename src/BlazorBindings.UnitTests/Navigation/{NonShellNavigationTests.cs => NonUriNavigationTests.cs} (98%) delete mode 100644 src/BlazorBindings.UnitTests/Navigation/StructuredRouteTest.cs rename src/BlazorBindings.UnitTests/Navigation/{ShellNavigationTests.cs => UriNavigationTests.cs} (58%) diff --git a/samples/ControlGallery/AppShell.razor b/samples/ControlGallery/AppShell.razor index 546c722d..fefcfbbe 100644 --- a/samples/ControlGallery/AppShell.razor +++ b/samples/ControlGallery/AppShell.razor @@ -73,7 +73,7 @@ @*Navigation*@ - + @*Other*@ diff --git a/samples/ControlGallery/Views/Navigation/ShellNavigationPage.razor b/samples/ControlGallery/Views/Navigation/ShellNavigationPage.razor deleted file mode 100644 index a41e0d24..00000000 --- a/samples/ControlGallery/Views/Navigation/ShellNavigationPage.razor +++ /dev/null @@ -1,43 +0,0 @@ -@page "/shell-navigation" -@using ControlGallery.Models -@inject Navigation NavigationManager - - - - - - - - - - - - - - - - - - - - -@code -{ - async Task NavigateNoParameter() => await NavigationManager.NavigateToAsync("/target"); -async Task NavigateWithName() => await NavigationManager.NavigateToAsync("/target/xamarin"); -async Task NavigateWithNameAndVersion() => await NavigationManager.NavigateToAsync("/target/xamarin/5"); -async Task NavigateWithNameVersionDate() => await NavigationManager.NavigateToAsync("/target/xamarin/5/2020-09-12"); -async Task NavigateWithLong() => await NavigationManager.NavigateToAsync("/long/55"); -async Task NavigateWithDouble() => await NavigationManager.NavigateToAsync("/double/55.43"); -async Task NavigateWithFloat() => await NavigationManager.NavigateToAsync("/float/55.43"); -async Task NavigateWithDecimal() => await NavigationManager.NavigateToAsync("/decimal/55.43"); -async Task NavigateWithGuid() => await NavigationManager.NavigateToAsync($"/guid/{Guid.NewGuid()}"); -async Task NavigateWithNullableGuid() => await NavigationManager.NavigateToAsync($"/nullable-guid/{Guid.NewGuid()}"); -async Task NavigateWithBool() => await NavigationManager.NavigateToAsync("/bool/true"); - -async Task NavigateWithAdditionalParameters() => await NavigationManager.NavigateToAsync("/target", new Dictionary - { - ["Query1"] = new NavigationParameterModel("Q1", "V1"), - ["Query2"] = new NavigationParameterModel("Q2", null) - }); -} diff --git a/samples/ControlGallery/Views/Navigation/ShellNavigationTargetPage.razor b/samples/ControlGallery/Views/Navigation/ShellNavigationTargetPage.razor deleted file mode 100644 index f32459da..00000000 --- a/samples/ControlGallery/Views/Navigation/ShellNavigationTargetPage.razor +++ /dev/null @@ -1,49 +0,0 @@ -@page "/target" -@page "/target/{Name}" -@page "/target/{Name}/{Version}" -@page "/target/{Name}/{Version}/{ReleaseDate}" -@page "/long/{Long}" -@page "/double/{Double}" -@page "/float/{Float}" -@page "/decimal/{Decimal}" -@page "/guid/{Guid}" -@page "/nullable-guid/{NullableGuid}" -@page "/bool/{Bool}" -@using ControlGallery.Models - - - - - @if (Name != null) - { - - } - - - - - - - - - - - - - - -@code { - -[Parameter] public string Name { get; set; } -[Parameter] public int Version { get; set; } -[Parameter] public DateTime ReleaseDate { get; set; } -[Parameter] public long Long { get; set; } -[Parameter] public double Double { get; set; } -[Parameter] public float Float { get; set; } -[Parameter] public decimal Decimal { get; set; } -[Parameter] public Guid Guid { get; set; } -[Parameter] public Guid? NullableGuid { get; set; } -[Parameter] public bool Bool { get; set; } -[Parameter] public NavigationParameterModel Query1 { get; set; } -[Parameter] public NavigationParameterModel Query2 { get; set; } -} diff --git a/samples/ControlGallery/Views/Navigation/UriNavigationPage.razor b/samples/ControlGallery/Views/Navigation/UriNavigationPage.razor new file mode 100644 index 00000000..f1e1bad9 --- /dev/null +++ b/samples/ControlGallery/Views/Navigation/UriNavigationPage.razor @@ -0,0 +1,45 @@ +@page "/shell-navigation" +@using ControlGallery.Models +@inject Navigation NavigationManager + + + + + + + + + + + + + + + + + + + + + +@code +{ + async Task NavigateNoParameter() => await NavigationManager.NavigateToAsync("/target"); + async Task NavigateWithName() => await NavigationManager.NavigateToAsync("/target/maui"); + async Task NavigateWithNameSubpath() => await NavigationManager.NavigateToAsync("/target/maui/subpath"); + async Task NavigateWithNameAndVersion() => await NavigationManager.NavigateToAsync("/target/maui/5"); + async Task NavigateWithNameVersionDate() => await NavigationManager.NavigateToAsync("/target/maui/5/2020-09-12"); + async Task NavigateWithLong() => await NavigationManager.NavigateToAsync("/long/55"); + async Task NavigateWithDouble() => await NavigationManager.NavigateToAsync("/double/55.43"); + async Task NavigateWithFloat() => await NavigationManager.NavigateToAsync("/float/55.43"); + async Task NavigateWithDecimal() => await NavigationManager.NavigateToAsync("/decimal/55.43"); + async Task NavigateWithGuid() => await NavigationManager.NavigateToAsync($"/guid/{Guid.NewGuid()}"); + async Task NavigateWithNullableGuid() => await NavigationManager.NavigateToAsync($"/nullable-guid/{Guid.NewGuid()}"); + async Task NavigateWithBool() => await NavigationManager.NavigateToAsync("/bool/true"); + + async Task NavigateWithAdditionalParameters() => await NavigationManager.NavigateToAsync("/target", new Dictionary + { + ["Query1"] = new NavigationParameterModel("Q1", "V1"), + ["Query2"] = new NavigationParameterModel("Q2", null) + }); +} diff --git a/samples/ControlGallery/Views/Navigation/UriNavigationTargetPage.razor b/samples/ControlGallery/Views/Navigation/UriNavigationTargetPage.razor new file mode 100644 index 00000000..a5c14adc --- /dev/null +++ b/samples/ControlGallery/Views/Navigation/UriNavigationTargetPage.razor @@ -0,0 +1,49 @@ +@page "/target" +@page "/target/{Name}" +@page "/target/{Name}/subpath" +@page "/target/{Name}/{Version:int}" +@page "/target/{Name}/{Version:int}/{ReleaseDate:datetime}" +@page "/long/{Long:long}" +@page "/double/{Double:double}" +@page "/float/{Float:float}" +@page "/decimal/{Decimal:decimal}" +@page "/guid/{Guid:guid}" +@page "/nullable-guid/{NullableGuid:guid?}" +@page "/bool/{Bool:bool}" +@using ControlGallery.Models + + + + + @if (Name != null) + { + + } + + + + + + + + + + + + + + +@code { + [Parameter] public string Name { get; set; } + [Parameter] public int Version { get; set; } + [Parameter] public DateTime ReleaseDate { get; set; } + [Parameter] public long Long { get; set; } + [Parameter] public double Double { get; set; } + [Parameter] public float Float { get; set; } + [Parameter] public decimal Decimal { get; set; } + [Parameter] public Guid Guid { get; set; } + [Parameter] public Guid? NullableGuid { get; set; } + [Parameter] public bool Bool { get; set; } + [Parameter] public NavigationParameterModel Query1 { get; set; } + [Parameter] public NavigationParameterModel Query2 { get; set; } +} diff --git a/src/BlazorBindings.Maui/MauiAppBuilderExtensions.cs b/src/BlazorBindings.Maui/MauiAppBuilderExtensions.cs index 40e0b142..90ab6109 100644 --- a/src/BlazorBindings.Maui/MauiAppBuilderExtensions.cs +++ b/src/BlazorBindings.Maui/MauiAppBuilderExtensions.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +using BlazorBindings.Maui.UriNavigation; +using Microsoft.AspNetCore.Components.Routing; using Microsoft.Extensions.Logging; using Microsoft.Maui.Hosting; @@ -15,6 +17,8 @@ public static MauiAppBuilder UseMauiBlazorBindings(this MauiAppBuilder builder) // Use factories for performance. builder.Services .AddSingleton(svcs => new Navigation(svcs)) + .AddSingleton(svcs => new MbbNavigationManager()) + .AddSingleton(svcs => new NavigationInterception()) .AddSingleton(services => services.GetRequiredService()) .AddSingleton(svcs => new MauiBlazorBindingsRenderer(svcs, svcs.GetRequiredService())); diff --git a/src/BlazorBindings.Maui/MauiBlazorBindingsRenderer.cs b/src/BlazorBindings.Maui/MauiBlazorBindingsRenderer.cs index 792b9e1b..f5759980 100644 --- a/src/BlazorBindings.Maui/MauiBlazorBindingsRenderer.cs +++ b/src/BlazorBindings.Maui/MauiBlazorBindingsRenderer.cs @@ -66,6 +66,15 @@ protected override ElementManager CreateNativeControlManager() return (elements[0], addComponentTask); } + internal async Task<(T Component, int ComponentId)> AddRootComponent(Dictionary initialParameters) + where T : IComponent + { + var component = (T)InstantiateComponent(typeof(T)); + var componentId = AssignRootComponentId(component); + await RenderRootComponentAsync(componentId, ParameterView.FromDictionary(initialParameters)); + return (component, componentId); + } + private async Task<(List Elements, Task Component)> GetElementsFromRenderedComponent(Type componentType, Dictionary parameters) { var container = new RootContainerHandler(); diff --git a/src/BlazorBindings.Maui/Navigation/Navigation.Uri.cs b/src/BlazorBindings.Maui/Navigation/Navigation.Uri.cs index 9995f82b..6815cd87 100644 --- a/src/BlazorBindings.Maui/Navigation/Navigation.Uri.cs +++ b/src/BlazorBindings.Maui/Navigation/Navigation.Uri.cs @@ -1,4 +1,4 @@ -using BlazorBindings.Maui.ShellNavigation; +using Microsoft.AspNetCore.Components.Routing; using System.Globalization; using System.Reflection; using MC = Microsoft.Maui.Controls; @@ -7,7 +7,9 @@ namespace BlazorBindings.Maui; public partial class Navigation { - private List Routes; + private NavigationManager _navigationManager; + private Router _router; + private TaskCompletionSource _waitForRouteSource; /// /// Performs URI-based navigation. @@ -18,14 +20,19 @@ public async Task NavigateToAsync(string uri, Dictionary paramet { ArgumentNullException.ThrowIfNull(uri); - Routes ??= FindRoutes(); + // I cannot use Blazor's route discovery directly as it is internal. + // Instead, I render Router internally with callbacks to get navigated page and parameters. + _navigationManager ??= _services.GetRequiredService(); + _router ??= await RenderRouter(); - var route = StructuredRoute.FindBestMatch(uri, Routes, parameters); + var routeTask = WaitForRoute(); + _navigationManager.NavigateTo(uri); + var route = await routeTask; if (route != null) { - var pars = GetParameters(route); - await Navigate(route.Route.Type, pars, NavigationTarget.Navigation, true); + var pars = GetParameters(route, parameters); + await Navigate(route.PageType, pars, NavigationTarget.Navigation, true); } else { @@ -33,39 +40,36 @@ public async Task NavigateToAsync(string uri, Dictionary paramet } } - //TODO This route matching could be better. Can we use the ASPNEt version? - private List FindRoutes() + private Dictionary GetParameters(RouteData routeData, Dictionary additionalParameters) { - var appType = MC.Application.Current.GetType(); - var assembly = appType.IsGenericType && appType.GetGenericTypeDefinition() == typeof(BlazorBindingsApplication<>) - ? appType.GenericTypeArguments[0].Assembly - : appType.Assembly; + if (routeData.RouteValues?.Count is not > 0 && additionalParameters?.Count is not > 0) + return null; - var result = new List(); - var pages = assembly.GetTypes().Where(x => x.IsSubclassOf(typeof(ComponentBase))); - foreach (var page in pages) + var result = new Dictionary(); + + if (routeData.RouteValues != null) { - //Find each @page on a page. There can be multiple. - var routes = page.GetCustomAttributes(); - foreach (var route in routes) + foreach (var (key, value) in ConvertParameters(routeData.PageType, routeData.RouteValues)) { - if (route.Template == "/") - { - // This route can be used in Hybrid apps and should be ignored by Shell (because Shell doesn't support empty routes anyway) - continue; - } - - var structuredRoute = new StructuredRoute(route.Template, page); + result.Add(key, value); + } + } - //Also register route in our own list for setting parameters and tracking if it is registered; - result.Add(structuredRoute); + if (additionalParameters != null) + { + foreach (var (key, value) in additionalParameters) + { + if (value != null) + result.Add(key, value); } } return result; } - private static Dictionary ConvertParameters(Type componentType, Dictionary parameters) + // This method is only needed for backward-compatibility - to allow assigning non-string parameters without + // specifying Route constraints. It should probably be removed in future versions. + private static Dictionary ConvertParameters(Type componentType, IReadOnlyDictionary parameters) { if (parameters is null) { @@ -76,37 +80,58 @@ private static Dictionary ConvertParameters(Type componentType, foreach (var keyValue in parameters) { - var propertyType = componentType.GetProperty(keyValue.Key)?.PropertyType ?? typeof(string); - if (!StringConverter.TryParse(propertyType, keyValue.Value, CultureInfo.InvariantCulture, out var parsedValue)) + var value = keyValue.Value; + + if (value is string stringValue) + { + var propertyType = componentType.GetProperty(keyValue.Key)?.PropertyType ?? typeof(string); + if (!StringConverter.TryParse(propertyType, stringValue, CultureInfo.InvariantCulture, out var parsedValue)) + { + throw new InvalidOperationException($"The value {keyValue.Value} can not be converted to a {propertyType.Name}"); + } + + convertedParameters[keyValue.Key] = parsedValue; + } + else { - throw new InvalidOperationException($"The value {keyValue.Value} can not be converted to a {propertyType.Name}"); + convertedParameters[keyValue.Key] = value; } - convertedParameters[keyValue.Key] = parsedValue; } return convertedParameters; } - private Dictionary GetParameters(StructuredRouteResult route) + private async Task RenderRouter() { - var parameters = ConvertParameters(route.Route.Type, route.PathParameters); + RenderFragment notFound = _ => _waitForRouteSource?.TrySetResult(null); + RenderFragment found = data => _ => _waitForRouteSource?.TrySetResult(data); - if (route.AdditionalParameters is not null) + (var router, _) = await _renderer.AddRootComponent(new() { - if (parameters is null) - { - parameters = route.AdditionalParameters; - } - else - { - foreach (var (key, value) in route.AdditionalParameters) - { - parameters.Add(key, value); - } - } - } + [nameof(Router.AppAssembly)] = GetDefaultAssembly(), + [nameof(Router.NotFound)] = notFound, + [nameof(Router.Found)] = found + }); - return parameters; + return router; } + + private async Task WaitForRoute() + { + _waitForRouteSource = new(); + var routeData = await _waitForRouteSource.Task; + _waitForRouteSource = null; + return routeData; + } + + private static Assembly GetDefaultAssembly() + { + var appType = MC.Application.Current.GetType(); + var assembly = appType.IsGenericType && appType.GetGenericTypeDefinition() == typeof(BlazorBindingsApplication<>) + ? appType.GenericTypeArguments[0].Assembly + : appType.Assembly; + return assembly; + } + } diff --git a/src/BlazorBindings.Maui/Navigation/Shell/StructuredRoute.cs b/src/BlazorBindings.Maui/Navigation/Shell/StructuredRoute.cs deleted file mode 100644 index 1f547bfe..00000000 --- a/src/BlazorBindings.Maui/Navigation/Shell/StructuredRoute.cs +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -namespace BlazorBindings.Maui.ShellNavigation; - -//Used to map blazor route syntax to forms query syntax -internal class StructuredRoute -{ -#pragma warning disable CA1056 // Uri properties should not be strings - public string OriginalUri { get; }//Full route as it is registered in the razor component -#pragma warning restore CA1056 // Uri properties should not be strings -#pragma warning disable CA1056 // Uri properties should not be strings - public string BaseUri { get; }//The route with all the parameters chopped off -#pragma warning restore CA1056 // Uri properties should not be strings - public int ParameterCount => ParameterKeys == null ? 0 : ParameterKeys.Count; - - public List ParameterKeys { get; } = new List(); - - - public Type Type { get; } - - public StructuredRoute(string originalRoute, Type type) - { - OriginalUri = originalRoute ?? throw new ArgumentNullException(nameof(originalRoute)); - Type = type; - var allRouteSegments = originalRoute.Split('/'); - var parameterKeys = allRouteSegments.Where(x => x.Contains('{') && x.Contains('}')); - - var constantSegments = allRouteSegments.Except(parameterKeys); - - var baseRoute = string.Join("/", constantSegments); - - BaseUri = baseRoute; - if (parameterKeys.Any()) - { - ParameterKeys = parameterKeys.Select(x => x.Trim('{').Trim('}')).ToList(); - } - } - - internal static StructuredRouteResult FindBestMatch(string uri, List routes, Dictionary additionalParameters) - { - var match = routes.FirstOrDefault(x => x.BaseUri == uri); - if (match != null && match.ParameterCount == 0) - { - return new StructuredRouteResult(match, additionalParameters: additionalParameters); - } - - var pieces = uri.Split('/').ToList(); - - var reversedPieces = pieces.Where(x => !string.IsNullOrEmpty(x)).ToList();//make a new copy - reversedPieces.Reverse(); - var parameters = new List(); - - var parameterCount = 1; - foreach (var piece in reversedPieces) - { - uri = uri.Substring(0, uri.Length - piece.Length); - uri = uri.TrimEnd('/'); - match = routes.FirstOrDefault(x => x.BaseUri == uri && x.ParameterCount == parameterCount); - parameters.Add(piece); - if (match != null) - { - break; - } - - parameterCount++; - } - - if (match is null) - return null; - - parameters.Reverse(); - return new StructuredRouteResult(match, parameters, additionalParameters); - } -} - -internal class StructuredRouteResult -{ - public StructuredRoute Route { get; } - public Dictionary PathParameters { get; } = new Dictionary(); - public Dictionary AdditionalParameters { get; } - - public StructuredRouteResult(StructuredRoute match, - List parameters = null, - Dictionary additionalParameters = null) - { - Route = match ?? throw new ArgumentNullException(nameof(match)); - - if (parameters is not null) - { - for (var i = 0; i < match.ParameterKeys.Count; i++) - { - PathParameters[match.ParameterKeys[i]] = parameters.ElementAtOrDefault(i); - } - } - - AdditionalParameters = additionalParameters; - } -} diff --git a/src/BlazorBindings.Maui/Navigation/Uri/MbbNavigationManager.cs b/src/BlazorBindings.Maui/Navigation/Uri/MbbNavigationManager.cs new file mode 100644 index 00000000..0cf4d219 --- /dev/null +++ b/src/BlazorBindings.Maui/Navigation/Uri/MbbNavigationManager.cs @@ -0,0 +1,15 @@ +namespace BlazorBindings.Maui.UriNavigation; + +internal class MbbNavigationManager : NavigationManager +{ + protected override void EnsureInitialized() + { + Initialize("app:///", "app:///"); + } + + protected override void NavigateToCore(string uri, NavigationOptions options) + { + Uri = ToAbsoluteUri(uri).AbsoluteUri; + NotifyLocationChanged(false); + } +} diff --git a/src/BlazorBindings.Maui/Navigation/Uri/NavigationInterception.cs b/src/BlazorBindings.Maui/Navigation/Uri/NavigationInterception.cs new file mode 100644 index 00000000..cc74807a --- /dev/null +++ b/src/BlazorBindings.Maui/Navigation/Uri/NavigationInterception.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Components.Routing; + +namespace BlazorBindings.Maui.UriNavigation; + +internal class NavigationInterception : INavigationInterception +{ + public Task EnableNavigationInterceptionAsync() => Task.CompletedTask; +} diff --git a/src/BlazorBindings.UnitTests/Components/PageWithUrl.razor b/src/BlazorBindings.UnitTests/Components/PageWithUrl.razor index a542e645..fb65f5cc 100644 --- a/src/BlazorBindings.UnitTests/Components/PageWithUrl.razor +++ b/src/BlazorBindings.UnitTests/Components/PageWithUrl.razor @@ -1,35 +1,67 @@ @implements IDisposable @page "/test/path/{Title}" +@page "/test/path/{Title}/subpath" +@page "/test/int-route/{I:int}/subpath" +@page "/test/nullable-long-route/{L:long?}" +@page "/test/datetime/{Dt}/without-constraint" - + + @code { -ContentPage _page; + ContentPage _page; -[Parameter] public string Title { get; set; } -public event Action OnDispose; + [Parameter] public string Title { get; set; } + [Parameter] public int I { get; set; } + [Parameter] public long? L { get; set; } + [Parameter] public DateTime? Dt { get; set; } + [Parameter] public string[] AdditionalText { get; set; } = Array.Empty(); -protected override void OnAfterRender(bool firstRender) -{ - if (firstRender) + public event Action OnDispose; + + protected override void OnAfterRender(bool firstRender) { - // This is needed to be able to get component from tests. - _page.NativeControl.SetValue(TestProperties.ComponentProperty, this); + if (firstRender) + { + // This is needed to be able to get component from tests. + _page.NativeControl.SetValue(TestProperties.ComponentProperty, this); + } + } + + public void Dispose() + { + OnDispose?.Invoke(); + OnDispose = null; + } + + public static void ValidateContent(MC.Element content, int i = 0, long? l = null, DateTime? dt = null, string[] additionalLines = null) + { + var contentPage = content as MC.ContentPage; + Assert.IsNotNull(contentPage); + + var layout = (MC.VerticalStackLayout)contentPage.Content; + var labelI = (MC.Label)layout[0]; + var labelL = (MC.Label)layout[1]; + var labelDt = (MC.Label)layout[2]; + var labelAdditional = (MC.Label)layout[3]; + + Assert.That(labelI.Text, Is.EqualTo(i.ToString())); + Assert.That(labelL.Text, Is.EqualTo(l?.ToString())); + Assert.That(labelDt.Text, Is.EqualTo(dt?.ToString())); + + if (additionalLines != null) + Assert.That(labelAdditional.Text, Is.EqualTo(string.Concat(additionalLines))); + + var nonPageContent = layout[4]; + NonPageContent.ValidateContent(nonPageContent); } -} - -public void Dispose() -{ - OnDispose?.Invoke(); - OnDispose = null; -} - -public static void ValidateContent(MC.Element content) -{ - var contentPage = content as MC.ContentPage; - Assert.IsNotNull(contentPage); - NonPageContent.ValidateContent(contentPage.Content); -} } \ No newline at end of file diff --git a/src/BlazorBindings.UnitTests/Navigation/NonShellNavigationTests.cs b/src/BlazorBindings.UnitTests/Navigation/NonUriNavigationTests.cs similarity index 98% rename from src/BlazorBindings.UnitTests/Navigation/NonShellNavigationTests.cs rename to src/BlazorBindings.UnitTests/Navigation/NonUriNavigationTests.cs index e51a1063..55f03b6e 100644 --- a/src/BlazorBindings.UnitTests/Navigation/NonShellNavigationTests.cs +++ b/src/BlazorBindings.UnitTests/Navigation/NonUriNavigationTests.cs @@ -8,13 +8,13 @@ namespace BlazorBindings.UnitTests.Navigation; [TestFixture(nameof(MC.Shell))] [TestFixture(nameof(MC.NavigationPage))] -public class NonShellNavigationTests +public class NonUriNavigationTests { private readonly Maui.Navigation _navigationService; private readonly MC.INavigation _mauiNavigation; private readonly MC.Page _rootPage; - public NonShellNavigationTests(string root) + public NonUriNavigationTests(string root) { var mainPage = root == nameof(MC.Shell) ? (MC.Page)new MC.Shell { Items = { new MC.ContentPage { Title = "Root" } } } diff --git a/src/BlazorBindings.UnitTests/Navigation/StructuredRouteTest.cs b/src/BlazorBindings.UnitTests/Navigation/StructuredRouteTest.cs deleted file mode 100644 index 1711f3cb..00000000 --- a/src/BlazorBindings.UnitTests/Navigation/StructuredRouteTest.cs +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -using BlazorBindings.Maui.ShellNavigation; - -namespace BlazorBindings.UnitTests; - -public class StructuredRouteTests -{ - public class TestComponent : ComponentBase - { - [Parameter] public string StringParameter { get; set; } - [Parameter] public int IntParameter { get; set; } - public string NonParameter { get; set; } - } - - [Test] - public void NoParameterRouteIsBaseUri() - { - var uri = "/home"; - var route = new StructuredRoute(uri, typeof(TestComponent)); - - Assert.AreEqual(uri, route.BaseUri); - } - - [Test] - public void NoParameterRouteIsOriginalUri() - { - var uri = "/home"; - var route = new StructuredRoute(uri, typeof(TestComponent)); - - Assert.AreEqual(uri, route.OriginalUri); - } - - [Test] - public void OneParameterOriginalUri() - { - var uri = "/home/{StringParameter}"; - var route = new StructuredRoute(uri, typeof(TestComponent)); - - Assert.AreEqual(uri, route.OriginalUri); - } - - [Test] - public void OneParameterBaseUri() - { - var uri = "/home/{StringParameter}"; - var route = new StructuredRoute(uri, typeof(TestComponent)); - - var expected = "/home"; - Assert.AreEqual(expected, route.BaseUri); - } - - [Test] - public void OneParameterCount() - { - var uri = "/home/{StringParameter}"; - var route = new StructuredRoute(uri, typeof(TestComponent)); - - var expected = 1; - Assert.AreEqual(expected, route.ParameterCount); - } - - [Test] - public void OneParameterKey() - { - var key = "StringParameter"; - var uri = "/home/{StringParameter}"; - var route = new StructuredRoute(uri, typeof(TestComponent)); - - Assert.AreEqual(key, route.ParameterKeys.FirstOrDefault()); - } -} diff --git a/src/BlazorBindings.UnitTests/Navigation/ShellNavigationTests.cs b/src/BlazorBindings.UnitTests/Navigation/UriNavigationTests.cs similarity index 58% rename from src/BlazorBindings.UnitTests/Navigation/ShellNavigationTests.cs rename to src/BlazorBindings.UnitTests/Navigation/UriNavigationTests.cs index 47c87745..80265fd0 100644 --- a/src/BlazorBindings.UnitTests/Navigation/ShellNavigationTests.cs +++ b/src/BlazorBindings.UnitTests/Navigation/UriNavigationTests.cs @@ -5,12 +5,12 @@ namespace BlazorBindings.UnitTests.Navigation; -public class ShellNavigationTests +public class UriNavigationTests { private readonly Maui.Navigation _navigationService; private readonly MC.INavigation _mauiNavigation; - public ShellNavigationTests() + public UriNavigationTests() { var shell = new MC.Shell { Items = { new MC.ContentPage { Title = "Root" } } }; var sp = TestServiceProvider.Create(); @@ -19,16 +19,63 @@ public ShellNavigationTests() _mauiNavigation = shell.Navigation; } + [TestCase("/test/path/TestTitle123/subpath")] + [TestCase("/test/path/TestTitle123")] + public async Task NavigateToPageWithUrlParameters(string uri) + { + await _navigationService.NavigateToAsync(uri); + + var mauiPage = _mauiNavigation.NavigationStack.Last(); + Assert.That(mauiPage.Title, Is.EqualTo("TestTitle123")); + PageWithUrl.ValidateContent(mauiPage); + } + [Test] - public async Task NavigateToPageWithUrlParameters() + public async Task NavigateToPageWithIntParameter() { - var title = "TestTitle123"; + await _navigationService.NavigateToAsync("/test/int-route/42/subpath"); + + var mauiPage = _mauiNavigation.NavigationStack.Last(); + PageWithUrl.ValidateContent(mauiPage, i: 42); + } - await _navigationService.NavigateToAsync($"/test/path/{title}"); + [TestCase("/test/nullable-long-route/1234", 1234L)] + [TestCase("/test/nullable-long-route/", null)] + public async Task NavigateToPageWithNullableLongParameter(string uri, long? expectedValue) + { + await _navigationService.NavigateToAsync(uri); var mauiPage = _mauiNavigation.NavigationStack.Last(); - Assert.That(mauiPage.Title, Is.EqualTo(title)); - PageWithUrl.ValidateContent(mauiPage); + PageWithUrl.ValidateContent(mauiPage, l: expectedValue); + } + + [Test] + public async Task NavigateToPageWithDateTimeParameterWithoutRouteConstraint() + { + await _navigationService.NavigateToAsync("/test/datetime/03-29-2023/without-constraint"); + + var mauiPage = _mauiNavigation.NavigationStack.Last(); + PageWithUrl.ValidateContent(mauiPage, dt: new DateTime(2023, 03, 29)); + } + + [Test] + public async Task NavigateToPageWithAdditionalParameter() + { + var lines = new[] { "Hello there!", "General Kenobi!" }; + await _navigationService.NavigateToAsync("/test/path/TestTitle123", new() + { + ["AdditionalText"] = lines + }); + + var mauiPage = _mauiNavigation.NavigationStack.Last(); + PageWithUrl.ValidateContent(mauiPage, additionalLines: lines); + } + + [Test] + public void ShouldFailIfRouteConstraintDoesNotMatch() + { + Assert.That(() => _navigationService.NavigateToAsync("/test/int-route/not-an-int/subpath"), + Throws.InvalidOperationException.With.Message.Contains("not registered")); } [Test] From 84518a93ee3fe938f9ab2b0ba540399e51e1cc09 Mon Sep 17 00:00:00 2001 From: Oleksandr Liakhevych Date: Fri, 31 Mar 2023 01:45:33 +0300 Subject: [PATCH 2/2] Fix Blazor navigation conflicts --- .../MauiAppBuilderExtensions.cs | 9 ++---- .../MauiBlazorBindingsRenderer.cs | 5 +++ .../MauiBlazorBindingsServiceProvider.cs | 31 +++++++++++++++++++ .../Navigation/Navigation.cs | 2 +- ...eption.cs => MbbNavigationInterception.cs} | 2 +- .../Navigation/NonUriNavigationTests.cs | 2 +- .../Navigation/UriNavigationTests.cs | 2 +- src/BlazorBindings.UnitTests/TestTypes.cs | 4 +-- 8 files changed, 45 insertions(+), 12 deletions(-) create mode 100644 src/BlazorBindings.Maui/MauiBlazorBindingsServiceProvider.cs rename src/BlazorBindings.Maui/Navigation/Uri/{NavigationInterception.cs => MbbNavigationInterception.cs} (72%) diff --git a/src/BlazorBindings.Maui/MauiAppBuilderExtensions.cs b/src/BlazorBindings.Maui/MauiAppBuilderExtensions.cs index 90ab6109..96db426f 100644 --- a/src/BlazorBindings.Maui/MauiAppBuilderExtensions.cs +++ b/src/BlazorBindings.Maui/MauiAppBuilderExtensions.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -using BlazorBindings.Maui.UriNavigation; -using Microsoft.AspNetCore.Components.Routing; using Microsoft.Extensions.Logging; using Microsoft.Maui.Hosting; @@ -16,11 +14,10 @@ public static MauiAppBuilder UseMauiBlazorBindings(this MauiAppBuilder builder) // Use factories for performance. builder.Services - .AddSingleton(svcs => new Navigation(svcs)) - .AddSingleton(svcs => new MbbNavigationManager()) - .AddSingleton(svcs => new NavigationInterception()) + .AddSingleton(svcs => new Navigation(svcs.GetRequiredService())) .AddSingleton(services => services.GetRequiredService()) - .AddSingleton(svcs => new MauiBlazorBindingsRenderer(svcs, svcs.GetRequiredService())); + .AddSingleton(svcs => new MauiBlazorBindingsRenderer(svcs.GetRequiredService(), svcs.GetRequiredService())) + .AddSingleton(svcs => new MauiBlazorBindingsServiceProvider(svcs)); return builder; } diff --git a/src/BlazorBindings.Maui/MauiBlazorBindingsRenderer.cs b/src/BlazorBindings.Maui/MauiBlazorBindingsRenderer.cs index f5759980..b9021813 100644 --- a/src/BlazorBindings.Maui/MauiBlazorBindingsRenderer.cs +++ b/src/BlazorBindings.Maui/MauiBlazorBindingsRenderer.cs @@ -15,6 +15,11 @@ public MauiBlazorBindingsRenderer(IServiceProvider serviceProvider, ILoggerFacto { } + internal MauiBlazorBindingsRenderer(MauiBlazorBindingsServiceProvider serviceProvider, ILoggerFactory loggerFactory) + : base(serviceProvider, loggerFactory) + { + } + public override Dispatcher Dispatcher { get; } = new MauiDeviceDispatcher(); public Task AddComponent(Type componentType, MC.Application parent, Dictionary parameters = null) diff --git a/src/BlazorBindings.Maui/MauiBlazorBindingsServiceProvider.cs b/src/BlazorBindings.Maui/MauiBlazorBindingsServiceProvider.cs new file mode 100644 index 00000000..35f658d8 --- /dev/null +++ b/src/BlazorBindings.Maui/MauiBlazorBindingsServiceProvider.cs @@ -0,0 +1,31 @@ +using BlazorBindings.Maui.UriNavigation; +using Microsoft.AspNetCore.Components.Routing; + +namespace BlazorBindings.Maui; + +// We cannot add Navigation services to common ServiceProvider as it leads to conflicts +// with Blazor navigation for hybrid apps. +// Therefore this wrapper is created to override those services for MBB cases only. +// Any better approach?... +internal class MauiBlazorBindingsServiceProvider : IServiceProvider +{ + private readonly IServiceProvider _serviceProvider; + private NavigationManager _navigationManager; + private INavigationInterception _navigationInterception; + + public MauiBlazorBindingsServiceProvider(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public object GetService(Type serviceType) + { + if (serviceType == typeof(NavigationManager)) + return _navigationManager ??= new MbbNavigationManager(); + + if (serviceType == typeof(INavigationInterception)) + return _navigationInterception ??= new MbbNavigationInterception(); + + return _serviceProvider.GetService(serviceType); + } +} diff --git a/src/BlazorBindings.Maui/Navigation/Navigation.cs b/src/BlazorBindings.Maui/Navigation/Navigation.cs index feb806e7..357d9a6b 100644 --- a/src/BlazorBindings.Maui/Navigation/Navigation.cs +++ b/src/BlazorBindings.Maui/Navigation/Navigation.cs @@ -11,7 +11,7 @@ public partial class Navigation : INavigation private readonly MauiBlazorBindingsRenderer _renderer; private Type _wrapperComponentType; - public Navigation(IServiceProvider services) + internal Navigation(MauiBlazorBindingsServiceProvider services) { _services = services; _renderer = services.GetRequiredService(); diff --git a/src/BlazorBindings.Maui/Navigation/Uri/NavigationInterception.cs b/src/BlazorBindings.Maui/Navigation/Uri/MbbNavigationInterception.cs similarity index 72% rename from src/BlazorBindings.Maui/Navigation/Uri/NavigationInterception.cs rename to src/BlazorBindings.Maui/Navigation/Uri/MbbNavigationInterception.cs index cc74807a..d99b6cc4 100644 --- a/src/BlazorBindings.Maui/Navigation/Uri/NavigationInterception.cs +++ b/src/BlazorBindings.Maui/Navigation/Uri/MbbNavigationInterception.cs @@ -2,7 +2,7 @@ namespace BlazorBindings.Maui.UriNavigation; -internal class NavigationInterception : INavigationInterception +internal class MbbNavigationInterception : INavigationInterception { public Task EnableNavigationInterceptionAsync() => Task.CompletedTask; } diff --git a/src/BlazorBindings.UnitTests/Navigation/NonUriNavigationTests.cs b/src/BlazorBindings.UnitTests/Navigation/NonUriNavigationTests.cs index 55f03b6e..5f6ae851 100644 --- a/src/BlazorBindings.UnitTests/Navigation/NonUriNavigationTests.cs +++ b/src/BlazorBindings.UnitTests/Navigation/NonUriNavigationTests.cs @@ -26,7 +26,7 @@ public NonUriNavigationTests(string root) var ctx = MC.Application.Current.Handler.MauiContext; var dsp = ctx.Services.GetService(); - _navigationService = new Maui.Navigation(sp); + _navigationService = sp.GetRequiredService(); _mauiNavigation = mainPage.Navigation; _rootPage = _mauiNavigation.NavigationStack[0]; } diff --git a/src/BlazorBindings.UnitTests/Navigation/UriNavigationTests.cs b/src/BlazorBindings.UnitTests/Navigation/UriNavigationTests.cs index 80265fd0..6a8eaa9b 100644 --- a/src/BlazorBindings.UnitTests/Navigation/UriNavigationTests.cs +++ b/src/BlazorBindings.UnitTests/Navigation/UriNavigationTests.cs @@ -15,7 +15,7 @@ public UriNavigationTests() var shell = new MC.Shell { Items = { new MC.ContentPage { Title = "Root" } } }; var sp = TestServiceProvider.Create(); MC.Application.Current = new TestApplication(sp) { MainPage = shell }; - _navigationService = new Maui.Navigation(sp); + _navigationService = sp.GetRequiredService(); _mauiNavigation = shell.Navigation; } diff --git a/src/BlazorBindings.UnitTests/TestTypes.cs b/src/BlazorBindings.UnitTests/TestTypes.cs index d55efc82..875e9fd0 100644 --- a/src/BlazorBindings.UnitTests/TestTypes.cs +++ b/src/BlazorBindings.UnitTests/TestTypes.cs @@ -74,9 +74,9 @@ public bool DispatchDelayed(TimeSpan delay, Action action) } } -public class TestBlazorBindingsRenderer : MauiBlazorBindingsRenderer +internal class TestBlazorBindingsRenderer : MauiBlazorBindingsRenderer { - public TestBlazorBindingsRenderer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory) : base(serviceProvider, loggerFactory) + public TestBlazorBindingsRenderer(MauiBlazorBindingsServiceProvider serviceProvider, ILoggerFactory loggerFactory) : base(serviceProvider, loggerFactory) { }