release/1.0.8-rc1 #42
4 changed files with 77 additions and 87 deletions
PostIt: homogenize VM-first navigation flows
commit
0065de7000
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue