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
4 changed files with 77 additions and 87 deletions
Showing only changes of commit 0065de7000 - Show all commits

PostIt: homogenize VM-first navigation flows

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

View file

@ -79,17 +79,23 @@ 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 circle = new CircleApiClient(api, "http://localhost/");
var acl = new BlogAclApiClient(api, "http://localhost/");
// Minimal DI graph: only what MainPageViewModel resolves // Minimal DI graph: only what MainPageViewModel resolves
// when the user clicks a navigation button. Today that's // when the user clicks a navigation button. Today that's
// SignaturePageViewModel (for the [DEV] Signature toolbar // SignaturePageViewModel / CirclesPageViewModel / ACL
// shortcut). Anything the SignaturePage or its VM touch // dependencies. The graph intentionally stays local to this
// transitively must be registered here too — the test // suite to avoid side effects from App.BuildServices() (real
// refuses to share App.BuildServices() because that one // token-store wiring).
// 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(); var services = new ServiceCollection();
services.AddSingleton(new Settings());
services.AddSingleton(circle);
services.AddSingleton(acl);
services.AddTransient<SignaturePageViewModel>(); services.AddTransient<SignaturePageViewModel>();
services.AddTransient<CirclesPageViewModel>();
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
services.AddTransient<PostAclDialog>();
var vm = new MainPageViewModel(blog, services: services.BuildServiceProvider()); 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;
@ -110,6 +116,13 @@ public class MainPageButtonsTests
{ {
var window = new MainWindow(); var window = new MainWindow();
var page = new MainPage { DataContext = vm }; var page = new MainPage { DataContext = vm };
var app = (PostIt.App)Application.Current!;
if (vm.Services is not null)
{
app.DataTemplates.Clear();
app.DataTemplates.Add(new ViewLocator(vm.Services));
}
app.AttachMainWindow(window);
window.Show(); window.Show();
window.NavRoot.PushAsync(page).GetAwaiter().GetResult(); window.NavRoot.PushAsync(page).GetAwaiter().GetResult();
return (window, page); return (window, page);
@ -132,8 +145,11 @@ public class MainPageButtonsTests
private static int ClickAndCapture(MainWindow window, Button button) private static int ClickAndCapture(MainWindow window, Button button)
{ {
var stackBefore = window.NavRoot.NavigationStack.Count; var stackBefore = window.NavRoot.NavigationStack.Count;
button.Focus(); button.Command?.Execute(button.CommandParameter);
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None); if (button.Command is IAsyncRelayCommand asyncCommand)
{
asyncCommand.ExecutionTask?.GetAwaiter().GetResult();
}
return stackBefore; return stackBefore;
} }
@ -200,7 +216,7 @@ public class MainPageButtonsTests
// The click must push SignaturePage on top of NavRoot. // The click must push SignaturePage on top of NavRoot.
// The ServiceCollection registered in MakeViewModel provides // The ServiceCollection registered in MakeViewModel provides
// SignaturePageViewModel so the command can resolve it via // SignaturePageViewModel so the command can resolve it via
// DI and assign it to CurrentViewModel; the ViewLocator // DI and call App.PushPage; the ViewLocator
// then maps SignaturePageViewModel -> SignaturePage and // then maps SignaturePageViewModel -> SignaturePage and
// the binding pushes the page. // the binding pushes the page.
var vm = MakeViewModel(); var vm = MakeViewModel();

View file

@ -87,8 +87,7 @@ public partial class App : Application
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
var homePage = ServiceProvider.GetRequiredService<HomePage>(); var homeVm = ServiceProvider.GetRequiredService<HomePageViewModel>();
homePage.DataContext = ServiceProvider.GetRequiredService<HomePageViewModel>();
window = new MainWindow(); window = new MainWindow();
window.SessionBanner.DataContext = sessionStatus; window.SessionBanner.DataContext = sessionStatus;
@ -96,9 +95,8 @@ public partial class App : Application
// Build the navigation stack from scratch: HomePage is the // Build the navigation stack from scratch: HomePage is the
// root in both cases. App.BootAsync will push MainPage on // root in both cases. App.BootAsync will push MainPage on
// top if the silent refresh succeeds. // top if the silent refresh succeeds.
window.DataContext = homePage.DataContext;
desktop.MainWindow = window; desktop.MainWindow = window;
_ = window.NavRoot.PushAsync(homePage); _ = PushPageAsync(homeVm);
// When the user logs out, route back to HomePage. We // When the user logs out, route back to HomePage. We
// ReplaceAsync the current top so we don't grow the stack // ReplaceAsync the current top so we don't grow the stack
@ -108,8 +106,6 @@ public partial class App : Application
{ {
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!; var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
var nav = w.NavRoot; var nav = w.NavRoot;
var hp = ServiceProvider.GetRequiredService<HomePage>();
hp.DataContext = ServiceProvider.GetRequiredService<HomePageViewModel>();
_ = nav.PopToRootAsync(); _ = nav.PopToRootAsync();
}; };
@ -121,34 +117,6 @@ public partial class App : Application
_ = PushMainPageAsync(); _ = PushMainPageAsync();
}; };
// When the user clicks the "Paramètres" button on the
// session banner, push the SettingsPage singleton on top
// of the current navigation stack. The DataContext is
// already wired at composition time (see the
// provider.GetRequiredService<SettingsPage>().DataContext
// assignment above), so this handler is a pure
// navigation concern.
//
// Anti-empilement guard: if the SettingsPage is already
// at the top of the stack, do nothing. NavigationPage's
// PushAsync does not deduplicate; calling it twice with
// the same instance would push it a second time and the
// user would have to tap Back twice to leave. Reference
// comparison is correct here because SettingsPage is a
// singleton — there is exactly one instance to compare
// against.
sessionStatus.OpenSettingsRequested += () =>
{
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
var settingsPage = ServiceProvider.GetRequiredService<SettingsPage>();
var stack = w.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage))
{
return;
}
_ = w.NavRoot.PushAsync(settingsPage);
};
window.Opened += async (_, _) => await BootAsync(this.ServiceProvider, api); window.Opened += async (_, _) => await BootAsync(this.ServiceProvider, api);
} }
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView) else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
@ -247,6 +215,17 @@ public partial class App : Application
Settings.BindToServiceProvider(sp); Settings.BindToServiceProvider(sp);
} }
/// <summary>
/// Test-only hook: bind a concrete <see cref="MainWindow"/> so
/// command-driven navigation paths (<see cref="PushPage"/>) can
/// push onto a real <see cref="NavigationPage"/> in headless
/// fixtures that do not run the full desktop lifetime bootstrap.
/// </summary>
internal void AttachMainWindow(MainWindow mainWindow)
{
window = mainWindow ?? throw new ArgumentNullException(nameof(mainWindow));
}
private static void ApplyDarkMode(Settings settings) private static void ApplyDarkMode(Settings settings)
{ {
Application.Current!.RequestedThemeVariant = Application.Current!.RequestedThemeVariant =
@ -274,19 +253,18 @@ public partial class App : Application
} }
/// <summary> /// <summary>
/// Resolve a fresh <c>MainPage</c> + VM from DI and push it on top /// Resolve a fresh <c>MainPageViewModel</c> from DI and push its
/// mapped page (via <see cref="ViewLocator"/>) on top
/// of the current navigation stack. Used both by <see cref="BootAsync"/> /// of the current navigation stack. Used both by <see cref="BootAsync"/>
/// (silent refresh at boot) and by <c>SessionStatusViewModel.LoginSucceeded</c> /// (silent refresh at boot) and by <c>SessionStatusViewModel.LoginSucceeded</c>
/// (interactive login from the banner). Pulled out as a helper so /// (interactive login from the banner). Pulled out as a helper so
/// the two callers can't drift apart. /// the two callers can't drift apart.
/// </summary> /// </summary>
public static async Task PushMainPageAsync() public static Task PushMainPageAsync()
{ {
var app = (App)Current; var app = (App)Current;
var mainVm = app.ServiceProvider.GetRequiredService<MainPageViewModel>(); var mainVm = app.ServiceProvider.GetRequiredService<MainPageViewModel>();
var mainPage = app.ServiceProvider.GetRequiredService<MainPage>(); return app.PushPageAsync(mainVm);
mainPage.DataContext = mainVm;
await app.window.FindControl<NavigationPage>("NavRoot").PushAsync(mainPage).ConfigureAwait(true);
} }
private bool TryHandOffCustomSchemeUrl() private bool TryHandOffCustomSchemeUrl()
@ -322,6 +300,11 @@ public partial class App : Application
} }
internal void PushPage(ViewModelBase vm) internal void PushPage(ViewModelBase vm)
{
_ = PushPageAsync(vm);
}
internal Task PushPageAsync(ViewModelBase vm)
{ {
if (window is null) if (window is null)
{ {
@ -353,9 +336,9 @@ public partial class App : Application
var stack = window.NavRoot.NavigationStack; var stack = window.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page)) if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
{ {
return; return Task.CompletedTask;
} }
_ = window.NavRoot.PushAsync(page); return window.NavRoot.PushAsync(page);
} }
} }

View file

@ -109,18 +109,18 @@ public partial class MainPageViewModel : ViewModelBase
private SignaturePageViewModel ResolveSignatureModel() private SignaturePageViewModel ResolveSignatureModel()
{ {
var sp = Services ?? (Application.Current as App)?.ServiceProvider; var sp = ResolveServices();
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>(); return sp.GetRequiredService<SignaturePageViewModel>();
} }
private IServiceProvider ResolveServices()
{
return Services ?? (Application.Current as App)?.ServiceProvider ??
throw new InvalidOperationException(
"No IServiceProvider available for navigation. Inject one in tests " +
"or ensure App.ServiceProvider is initialized in production.");
}
public MainPageViewModel() public MainPageViewModel()
{ {
@ -351,7 +351,7 @@ public partial class MainPageViewModel : ViewModelBase
/// entry point is a SignalR push from Yavsc.Org ("devis /// entry point is a SignalR push from Yavsc.Org ("devis
/// received, sign here"); this command is the dev-time /// received, sign here"); this command is the dev-time
/// shortcut to reach the page without that infrastructure. /// shortcut to reach the page without that infrastructure.
/// Aligned on the same nav-via-CurrentViewModel pattern as /// Aligned on the same VM-first navigation pattern as
/// <see cref="OpenSettings"/>: the VM resolves the target VM /// <see cref="OpenSettings"/>: the VM resolves the target VM
/// through <see cref="Services"/>, the <c>ViewLocator</c> picks /// through <see cref="Services"/>, the <c>ViewLocator</c> picks
/// the matching <c>Control</c> at bind time. No /// the matching <c>Control</c> at bind time. No
@ -359,14 +359,17 @@ public partial class MainPageViewModel : ViewModelBase
/// access from the view layer. /// access from the view layer.
/// </summary> /// </summary>
[RelayCommand] [RelayCommand]
internal void OpenSignatureDev() internal async Task OpenSignatureDev()
{ {
((App)App.Current).PushPage(SignatureModel); await ((App)App.Current!).PushPageAsync(SignatureModel).ConfigureAwait(true);
} }
private ViewModelBase? GetACLViewModel(BlogPostDto selectedPost) private ViewModelBase GetACLViewModel(BlogPostDto selectedPost)
{ {
throw new NotImplementedException(); var sp = ResolveServices();
var aclClient = sp.GetRequiredService<BlogAclApiClient>();
var circleClient = sp.GetRequiredService<CircleApiClient>();
return new PostAclDialogViewModel(selectedPost, aclClient, circleClient);
} }
private async Task RefreshPostsAsync() private async Task RefreshPostsAsync()
@ -432,19 +435,16 @@ public partial class MainPageViewModel : ViewModelBase
[RelayCommand(CanExecute = nameof(CanManageAcl))] [RelayCommand(CanExecute = nameof(CanManageAcl))]
public void ManageAcl() public async Task ManageAcl()
{ {
if (SelectedPost is null) return; if (SelectedPost is null) return;
((App)App.Current).PushPage(GetACLViewModel(SelectedPost)); await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true);
} }
/// <summary>
/// Raised when the user asks to open the circles page (full
/// CRUD on their own circles). Same routing as
/// <see cref="ManageAclRequested"/>.
/// </summary>
public event EventHandler? OpenCirclesRequested;
[RelayCommand] [RelayCommand]
public void OpenCircles() => OpenCirclesRequested?.Invoke(this, EventArgs.Empty); public async Task OpenCircles()
{
var circlesVm = ResolveServices().GetRequiredService<CirclesPageViewModel>();
await ((App)App.Current!).PushPageAsync(circlesVm).ConfigureAwait(true);
}
} }

View file

@ -33,15 +33,6 @@ public partial class SessionStatusViewModel : ViewModelBase
/// <c>HomePage</c> so the user lands on the blog editor.</summary> /// <c>HomePage</c> so the user lands on the blog editor.</summary>
public event System.Action? LoginSucceeded; public event System.Action? LoginSucceeded;
/// <summary>Raised when the user clicks the "Paramètres" button on
/// the session banner. <c>App.axaml.cs</c> listens and pushes
/// <c>SettingsPage</c> (resolved from DI, bound to the canonical
/// <c>Settings</c> singleton) on top of the current navigation
/// stack. Same event pattern as <see cref="LogoutCompleted"/> and
/// <see cref="LoginSucceeded"/> so the VM stays decoupled from
/// <c>NavigationPage</c> / window lifetime.</summary>
public event System.Action? OpenSettingsRequested;
[ObservableProperty] [ObservableProperty]
public partial bool IsLoggedIn { get; private set; } public partial bool IsLoggedIn { get; private set; }
@ -145,10 +136,10 @@ public partial class SessionStatusViewModel : ViewModelBase
} }
[RelayCommand] [RelayCommand]
internal void OpenSettings() internal async Task OpenSettings()
{ {
var app = (App)App.Current; var app = (App)App.Current!;
app.PushPage(app.ServiceProvider.GetRequiredService<Settings>()); await app.PushPageAsync(app.ServiceProvider.GetRequiredService<Settings>()).ConfigureAwait(true);
} }
} }