fix/inactive-toolbar-buttons #38

Merged
notazof merged 12 commits from fix/inactive-toolbar-buttons into release/1.0.8-rc1 2026-08-19 20:02:49 +01:00
7 changed files with 133 additions and 67 deletions
Showing only changes of commit 3fbbafc454 - Show all commits

PostIt: route VM navigation through ViewLocator

Paul Schneider 2026-08-19 17:01:58 +01:00
Signed by: notazof
GPG key ID: 1DD5D838E5343B06

View file

@ -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);

View file

@ -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 />

View file

@ -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);
}
} }

View file

@ -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>

View file

@ -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>());
} }
} }

View file

@ -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>

View file

@ -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);
}
}
} }