PostIt: route VM navigation through ViewLocator
This commit is contained in:
parent
30a0e10bae
commit
3fbbafc454
7 changed files with 133 additions and 67 deletions
|
|
@ -5,6 +5,7 @@ using Avalonia.Headless.XUnit;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Yavsc.Api.Client;
|
using Yavsc.Api.Client;
|
||||||
using Yavsc.Blogspot;
|
using Yavsc.Blogspot;
|
||||||
using PostIt.Services;
|
using PostIt.Services;
|
||||||
|
|
@ -78,7 +79,18 @@ public class MainPageButtonsTests
|
||||||
{
|
{
|
||||||
var api = new ThrowingApi();
|
var api = new ThrowingApi();
|
||||||
var blog = new BlogApiClient(api, "http://localhost/");
|
var blog = new BlogApiClient(api, "http://localhost/");
|
||||||
var vm = new MainPageViewModel(blog);
|
// Minimal DI graph: only what MainPageViewModel resolves
|
||||||
|
// when the user clicks a navigation button. Today that's
|
||||||
|
// SignaturePageViewModel (for the [DEV] Signature toolbar
|
||||||
|
// shortcut). Anything the SignaturePage or its VM touch
|
||||||
|
// transitively must be registered here too — the test
|
||||||
|
// refuses to share App.BuildServices() because that one
|
||||||
|
// constructs a real YavscApiClient pointing at the host's
|
||||||
|
// token store, which is exactly the noise we want out of
|
||||||
|
// a UI-driving test.
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddTransient<SignaturePageViewModel>();
|
||||||
|
var vm = new MainPageViewModel(blog, services: services.BuildServiceProvider());
|
||||||
if (selectedPost is not null) vm.SelectedPost = selectedPost;
|
if (selectedPost is not null) vm.SelectedPost = selectedPost;
|
||||||
return vm;
|
return vm;
|
||||||
}
|
}
|
||||||
|
|
@ -183,16 +195,20 @@ public class MainPageButtonsTests
|
||||||
[AvaloniaFact]
|
[AvaloniaFact]
|
||||||
public void Signature_dev_button_click_pushes_a_page_onto_nav_stack()
|
public void Signature_dev_button_click_pushes_a_page_onto_nav_stack()
|
||||||
{
|
{
|
||||||
// Arrange: the "[DEV] Signature" button uses XAML's
|
// Arrange: the "[DEV] Signature" button is bound to the
|
||||||
// Click="OpenSignatureDev" attribute, so we don't bind
|
// MainPageViewModel.OpenSignatureDevCommand [RelayCommand].
|
||||||
// a Command here — we drive the click directly. The
|
// The click must push SignaturePage on top of NavRoot.
|
||||||
// handler resolves App.ServiceProvider, which is null
|
// The ServiceCollection registered in MakeViewModel provides
|
||||||
// in a unit test, and early-returns; that is the
|
// SignaturePageViewModel so the command can resolve it via
|
||||||
// failure mode the test pins.
|
// DI and assign it to CurrentViewModel; the ViewLocator
|
||||||
|
// then maps SignaturePageViewModel -> SignaturePage and
|
||||||
|
// the binding pushes the page.
|
||||||
var vm = MakeViewModel();
|
var vm = MakeViewModel();
|
||||||
var (window, page) = MountMainPage(vm);
|
var (window, page) = MountMainPage(vm);
|
||||||
|
|
||||||
var signatureButton = page.OpenSignatureDevButton;
|
var signatureButton = page.OpenSignatureDevButton;
|
||||||
|
Assert.NotNull(signatureButton.Command);
|
||||||
|
Assert.True(signatureButton.Command.CanExecute(null));
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var stackBefore = ClickAndCapture(window, signatureButton);
|
var stackBefore = ClickAndCapture(window, signatureButton);
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,7 @@
|
||||||
xmlns:local="using:PostIt"
|
xmlns:local="using:PostIt"
|
||||||
x:Class="PostIt.App">
|
x:Class="PostIt.App">
|
||||||
|
|
||||||
<Application.DataTemplates>
|
<!-- ViewLocator is registered in App.axaml.cs with the real DI container. -->
|
||||||
<local:ViewLocator/>
|
|
||||||
</Application.DataTemplates>
|
|
||||||
|
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
<FluentTheme />
|
<FluentTheme />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
|
@ -48,11 +49,11 @@ public partial class App : Application
|
||||||
// build is ever reconfigured to skip the early check.
|
// build is ever reconfigured to skip the early check.
|
||||||
if (TryHandOffCustomSchemeUrl()) return;
|
if (TryHandOffCustomSchemeUrl()) return;
|
||||||
|
|
||||||
var serviceProvider = BuildServices();
|
this.ServiceProvider = BuildServices();
|
||||||
AttachServiceProvider(serviceProvider);
|
AttachServiceProvider(ServiceProvider);
|
||||||
var settings = serviceProvider.GetRequiredService<Settings>();
|
var settings = ServiceProvider.GetRequiredService<Settings>();
|
||||||
var sessionStatus = serviceProvider.GetRequiredService<SessionStatusViewModel>();
|
var sessionStatus = ServiceProvider.GetRequiredService<SessionStatusViewModel>();
|
||||||
var api = serviceProvider.GetRequiredService<YavscApiClient>();
|
var api = ServiceProvider.GetRequiredService<YavscApiClient>();
|
||||||
|
|
||||||
DataTemplates.Clear();
|
DataTemplates.Clear();
|
||||||
DataTemplates.Add(new ViewLocator(ServiceProvider));
|
DataTemplates.Add(new ViewLocator(ServiceProvider));
|
||||||
|
|
@ -148,7 +149,7 @@ public partial class App : Application
|
||||||
_ = w.NavRoot.PushAsync(settingsPage);
|
_ = w.NavRoot.PushAsync(settingsPage);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.Opened += async (_, _) => await BootAsync(ServiceProvider, api);
|
window.Opened += async (_, _) => await BootAsync(this.ServiceProvider, api);
|
||||||
}
|
}
|
||||||
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
|
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
|
||||||
{
|
{
|
||||||
|
|
@ -320,4 +321,41 @@ public partial class App : Application
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal void PushPage(ViewModelBase vm)
|
||||||
|
{
|
||||||
|
if (window is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("MainWindow is not initialized yet.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var template = DataTemplates.FirstOrDefault(t => t.Match(vm));
|
||||||
|
if (template is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"No IDataTemplate found for {vm.GetType().Name}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var view = template.Build(vm);
|
||||||
|
if (view is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Template for {vm.GetType().Name} returned <null>.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view is not Page page)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Template for {vm.GetType().Name} returned {view.GetType().Name}, expected a Page.");
|
||||||
|
}
|
||||||
|
|
||||||
|
page.DataContext = vm;
|
||||||
|
|
||||||
|
// Avoid stacking the same singleton page twice (e.g. SettingsPage).
|
||||||
|
var stack = window.NavRoot.NavigationStack;
|
||||||
|
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = window.NavRoot.PushAsync(page);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,14 @@ using System;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Yavsc.Blogspot;
|
using Yavsc.Blogspot;
|
||||||
using Yavsc.Api.Client;
|
using Yavsc.Api.Client;
|
||||||
using PostIt.Services;
|
using PostIt.Services;
|
||||||
|
using PostIt.Views;
|
||||||
|
|
||||||
namespace PostIt.ViewModels;
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
|
|
@ -47,9 +50,6 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial bool DraftIsPublished { get; set; }
|
public partial bool DraftIsPublished { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
public partial ViewModelBase? CurrentViewModel { get; set; }
|
|
||||||
|
|
||||||
public Settings SettingsModel { get; }
|
public Settings SettingsModel { get; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
|
|
@ -81,9 +81,46 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public BlogApiClient? BlogClient { get; }
|
public BlogApiClient? BlogClient { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DI container the VM uses to resolve navigation targets
|
||||||
|
/// (other ViewModels) when the user clicks a toolbar button
|
||||||
|
/// that opens a sub-screen. Owned by <c>App.ServiceProvider</c>
|
||||||
|
/// in production; injected directly in tests. The VM resolves
|
||||||
|
/// <em>ViewModels</em> via this provider, never Views — the
|
||||||
|
/// actual <see cref="Control"/> to push is decided by
|
||||||
|
/// <see cref="ViewLocator"/> at bind time, per CONTRIBUTING.md
|
||||||
|
/// §"Navigation (PostIt)".
|
||||||
|
/// </summary>
|
||||||
|
public IServiceProvider? Services { get; }
|
||||||
|
|
||||||
|
private SignaturePageViewModel? _signatureModel;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolved on first access. Lazy so the test path (which
|
||||||
|
/// never pushes <c>SignaturePage</c>) does not require a
|
||||||
|
/// fully-built DI graph just to construct the VM. Mirrors the
|
||||||
|
/// pattern of <see cref="SettingsModel"/> for the Settings case.
|
||||||
|
/// </summary>
|
||||||
|
public SignaturePageViewModel SignatureModel =>
|
||||||
|
_signatureModel ??= ResolveSignatureModel();
|
||||||
|
|
||||||
public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
|
public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
|
||||||
public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
|
public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
|
||||||
|
|
||||||
|
private SignaturePageViewModel ResolveSignatureModel()
|
||||||
|
{
|
||||||
|
var sp = Services ?? (Application.Current as App)?.ServiceProvider;
|
||||||
|
if (sp is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Cannot resolve SignaturePageViewModel: no IServiceProvider " +
|
||||||
|
"was injected and App.ServiceProvider is null. This is a " +
|
||||||
|
"test-time wiring bug — the test must construct an " +
|
||||||
|
"IServiceProvider that registers SignaturePageViewModel.");
|
||||||
|
}
|
||||||
|
return sp.GetRequiredService<SignaturePageViewModel>();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public MainPageViewModel()
|
public MainPageViewModel()
|
||||||
{
|
{
|
||||||
|
|
@ -115,7 +152,6 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
DraftTitle = string.Empty;
|
DraftTitle = string.Empty;
|
||||||
DraftArticle = string.Empty;
|
DraftArticle = string.Empty;
|
||||||
DraftIsPublished = false;
|
DraftIsPublished = false;
|
||||||
CurrentViewModel = this;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Save is enabled as soon as the user has typed
|
/// <summary>Save is enabled as soon as the user has typed
|
||||||
|
|
@ -135,10 +171,11 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
/// <see cref="BlogApiClient"/>. Production code uses the
|
/// <see cref="BlogApiClient"/>. Production code uses the
|
||||||
/// (Settings, BlogApiClient) overload below.
|
/// (Settings, BlogApiClient) overload below.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null)
|
public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null)
|
||||||
{
|
{
|
||||||
SettingsModel = new Settings();
|
SettingsModel = new Settings();
|
||||||
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ;
|
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ;
|
||||||
|
Services = services;
|
||||||
|
|
||||||
Init(settings);
|
Init(settings);
|
||||||
}
|
}
|
||||||
|
|
@ -309,10 +346,22 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DEV ONLY: open the signature capture page. The production
|
||||||
|
/// entry point is a SignalR push from Yavsc.Org ("devis
|
||||||
|
/// received, sign here"); this command is the dev-time
|
||||||
|
/// shortcut to reach the page without that infrastructure.
|
||||||
|
/// Aligned on the same nav-via-CurrentViewModel pattern as
|
||||||
|
/// <see cref="OpenSettings"/>: the VM resolves the target VM
|
||||||
|
/// through <see cref="Services"/>, the <c>ViewLocator</c> picks
|
||||||
|
/// the matching <c>Control</c> at bind time. No
|
||||||
|
/// <c>Click</code> handler, no <c>App.ServiceProvider</c>
|
||||||
|
/// access from the view layer.
|
||||||
|
/// </summary>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
internal void OpenSettings()
|
internal void OpenSignatureDev()
|
||||||
{
|
{
|
||||||
CurrentViewModel = SettingsModel;
|
((App)App.Current).PushPage(SignatureModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ViewModelBase? GetACLViewModel(BlogPostDto selectedPost)
|
private ViewModelBase? GetACLViewModel(BlogPostDto selectedPost)
|
||||||
|
|
@ -386,7 +435,7 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
public void ManageAcl()
|
public void ManageAcl()
|
||||||
{
|
{
|
||||||
if (SelectedPost is null) return;
|
if (SelectedPost is null) return;
|
||||||
CurrentViewModel = GetACLViewModel(SelectedPost);
|
((App)App.Current).PushPage(GetACLViewModel(SelectedPost));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using PostIt.Services;
|
using PostIt.Services;
|
||||||
|
|
||||||
namespace PostIt.ViewModels;
|
namespace PostIt.ViewModels;
|
||||||
|
|
@ -144,9 +145,10 @@ public partial class SessionStatusViewModel : ViewModelBase
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public async System.Threading.Tasks.Task OpenSettingsCommand()
|
internal void OpenSettings()
|
||||||
{
|
{
|
||||||
OpenSettingsRequested?.Invoke();
|
var app = (App)App.Current;
|
||||||
await System.Threading.Tasks.Task.CompletedTask;
|
app.PushPage(app.ServiceProvider.GetRequiredService<Settings>());
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,8 @@
|
||||||
MainPage.axaml.cs once the SignalR handler lands.
|
MainPage.axaml.cs once the SignalR handler lands.
|
||||||
-->
|
-->
|
||||||
<Button x:Name="OpenSignatureDevButton"
|
<Button x:Name="OpenSignatureDevButton"
|
||||||
|
Command="{Binding OpenSignatureDev}"
|
||||||
Content="[DEV] Signature"
|
Content="[DEV] Signature"
|
||||||
Click="OpenSignatureDev"
|
|
||||||
ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" />
|
ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,4 @@
|
||||||
using System;
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using PostIt.ViewModels;
|
|
||||||
using Yavsc.Blogspot;
|
|
||||||
using Yavsc.Api.Client;
|
|
||||||
|
|
||||||
namespace PostIt.Views;
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
|
@ -15,34 +8,4 @@ public partial class MainPage : ContentPage
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
MainPageViewModel? _vm;
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DEV ONLY: temporary shortcut to open the signature capture
|
|
||||||
/// page from the blog editor. The production entry point is a
|
|
||||||
/// SignalR push from Yavsc.Org ("devis received, sign here"),
|
|
||||||
/// which is the only path that carries the devis identifier
|
|
||||||
/// needed to bind the capture to a specific contract.
|
|
||||||
///
|
|
||||||
/// Remove this method and the corresponding button in
|
|
||||||
/// MainPage.axaml.cs once the SignalR handler lands.
|
|
||||||
/// </summary>
|
|
||||||
private void OpenSignatureDev(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
// Resolve via the App's DI container so the page gets
|
|
||||||
// the canonical services (Api client, settings, ...).
|
|
||||||
var app = Application.Current as App;
|
|
||||||
var services = app?.ServiceProvider;
|
|
||||||
if (services is null) return;
|
|
||||||
|
|
||||||
var page = services.GetRequiredService<SignaturePage>();
|
|
||||||
page.DataContext = services.GetRequiredService<SignaturePageViewModel>();
|
|
||||||
|
|
||||||
if (this.VisualRoot is MainWindow window)
|
|
||||||
{
|
|
||||||
_ = window.NavRoot.PushAsync(page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue