fix/inactive-toolbar-buttons #38
24 changed files with 684 additions and 350 deletions
11
.vscode/mcp.json
vendored
11
.vscode/mcp.json
vendored
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"servers": {
|
||||
"openclaw": {
|
||||
"type": "stdio",
|
||||
"command": "/home/paul/.nvm/versions/node/v22.23.0/bin/node",
|
||||
"args": [
|
||||
"/home/paul/Workspace/tools/openclaw-mcp-server.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,57 @@ Les tests sont répartis en :
|
|||
item « Tests d'intégration smoke par BC ».
|
||||
- `src/PostIt.Tests/` — tests unitaires du client desktop PostIt.
|
||||
|
||||
## Navigation (PostIt)
|
||||
|
||||
La navigation est centralisée dans
|
||||
`App.PushPageAsync(ViewModelBase vm)` (`src/PostIt/PostIt/App.axaml.cs`).
|
||||
Pour ouvrir un écran, un ViewModel (généralement dans une
|
||||
commande `[RelayCommand]`) appelle
|
||||
`await ((App)App.Current!).PushPageAsync(targetVm).ConfigureAwait(true);`.
|
||||
`PushPageAsync` résout la `Control` correspondante via le
|
||||
`ViewLocator` (un `IDataTemplate` enregistré dans
|
||||
`Application.DataTemplates` au boot), l'identifie comme
|
||||
`Page`, lui assigne le VM comme `DataContext`, et appelle
|
||||
`NavRoot.PushAsync(page)`. Une garde anti-empilement
|
||||
compare par référence la nouvelle page au sommet courant
|
||||
de la stack pour éviter un push doublon.
|
||||
|
||||
Pour qu'une nouvelle page soit navigable, il faut *deux*
|
||||
enregistrements : la page dans le DI (`AddTransient<TPage>`
|
||||
ou `AddSingleton<TPage>`) **et** une case dans le `switch`
|
||||
de `ViewLocator.Build`. Si l'un manque, l'app affiche
|
||||
"No view for X" sans crash.
|
||||
|
||||
Règles :
|
||||
|
||||
- On n'instancie jamais une `View` à la main depuis un
|
||||
ViewModel, on ne récupère jamais une `View` depuis la DI
|
||||
directement dans un ViewModel.
|
||||
- Le ViewModel qui déclenche la nav ne pousse pas lui-même
|
||||
la page ; il appelle `App.PushPageAsync(vm)` et laisse
|
||||
`App` orchestrer le `PushAsync` physique.
|
||||
- Le ViewModel qui déclenche la nav ne capture pas de
|
||||
référence à `MainWindow` ou `NavigationPage`. Il passe
|
||||
par `App.Current` (l'app Avalonia est un singleton).
|
||||
|
||||
Exemple canonique (depuis `MainPageViewModel`) :
|
||||
|
||||
```csharp
|
||||
[RelayCommand]
|
||||
internal async Task OpenSettings()
|
||||
{
|
||||
var settingsVm = ((App)App.Current!).ServiceProvider
|
||||
.GetRequiredService<Settings>();
|
||||
await ((App)App.Current!).PushPageAsync(settingsVm)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
```
|
||||
|
||||
Cf. [doc/architecture/postit.md](./doc/architecture/postit.md)
|
||||
pour la topologie complète (host de navigation,
|
||||
`SessionStatusViewModel`, signaux de cycle de vie vs nav
|
||||
utilisateur).
|
||||
|
||||
## Conventions de code
|
||||
|
||||
Le repo applique `.editorconfig` (UTF-8, LF, `indent_size = 4` en
|
||||
|
|
|
|||
|
|
@ -129,30 +129,51 @@ le DI est construit. Ordre, dans cet ordre :
|
|||
## Navigation
|
||||
|
||||
Le host de navigation est un `NavigationPage x:Name="NavRoot"`
|
||||
posé sur `MainWindow.axaml`. La pile est gérée par les
|
||||
événements du `SessionStatusViewModel` :
|
||||
posé sur `MainWindow.axaml`. La pile est gérée par deux
|
||||
mécanismes distincts :
|
||||
|
||||
| Événement | Effet |
|
||||
|---------------------------------|------------------------------------------------------------------------|
|
||||
| `LoginSucceeded` | `PushAsync(MainPage)` au-dessus de `HomePage`. |
|
||||
| `LogoutCompleted` | `PopToRootAsync()` (revient à `HomePage`). |
|
||||
| `OpenSettingsRequested` | `PushAsync(SettingsPage)` au-dessus de la page courante. |
|
||||
1. **Nav utilisateur (VM-first)** : un ViewModel (souvent dans
|
||||
une commande `[RelayCommand]`) appelle
|
||||
`await ((App)App.Current!).PushPageAsync(targetVm).ConfigureAwait(true);`.
|
||||
`App.PushPageAsync` (`src/PostIt/PostIt/App.axaml.cs`)
|
||||
résout la `Control` correspondante via le `ViewLocator`
|
||||
enregistré dans `Application.DataTemplates`, l'identifie
|
||||
comme `Page`, lui assigne le VM comme `DataContext`, et
|
||||
appelle `NavRoot.PushAsync(page)`. C'est le seul chemin
|
||||
pour les boutons de la toolbar, les `OpenSettings` /
|
||||
`OpenCircles` / `ManageAcl` / `OpenSignatureDev`, et
|
||||
toute autre nav déclenchée par un ViewModel.
|
||||
|
||||
2. **Signaux de cycle de vie** : le `SessionStatusViewModel`
|
||||
lève des événements consommés dans
|
||||
`App.OnFrameworkInitializationCompleted` pour orchestrer
|
||||
la nav de boot :
|
||||
|
||||
| Événement | Effet |
|
||||
|---------------------|------------------------------------------------------------------|
|
||||
| `LoginSucceeded` | `PushAsync(MainPage)` au-dessus de `HomePage` (post-login). |
|
||||
| `LogoutCompleted` | `PopToRootAsync()` (revient à `HomePage`). |
|
||||
|
||||
Ces events ne sont **pas** un canal de nav utilisateur ; ils
|
||||
portent une transition d'état applicatif (authentification
|
||||
établie / perdue) et c'est `App` qui choisit d'en faire une
|
||||
transition de pile.
|
||||
|
||||
### Garde anti-empilement
|
||||
|
||||
`NavigationPage.PushAsync` n'est pas idempotent : pousser deux
|
||||
fois la même instance l'empile deux fois, et l'utilisateur doit
|
||||
taper **Retour** N fois pour sortir. Le handler
|
||||
`OpenSettingsRequested` est gardé pour bloquer ce cas :
|
||||
taper **Retour** N fois pour sortir. La garde est implémentée
|
||||
dans `App.PushPageAsync` (et consommée par tous les chemins
|
||||
de nav utilisateur) :
|
||||
|
||||
```csharp
|
||||
var settingsPage = provider.GetRequiredService<SettingsPage>();
|
||||
var stack = w.NavRoot.NavigationStack;
|
||||
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage))
|
||||
var stack = window.NavRoot.NavigationStack;
|
||||
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
|
||||
{
|
||||
return; // déjà au sommet, no-op silencieux
|
||||
return Task.CompletedTask; // déjà au sommet, no-op silencieux
|
||||
}
|
||||
_ = w.NavRoot.PushAsync(settingsPage);
|
||||
return window.NavRoot.PushAsync(page);
|
||||
```
|
||||
|
||||
La comparaison est par référence, pas par type : on ne veut
|
||||
|
|
@ -178,9 +199,11 @@ qui ne tiendrait plus).
|
|||
- `SessionStatusViewModel` est le seul VM avec une durée de vie
|
||||
**process-entière** (singleton). Il survit à toutes les
|
||||
navigations, expose `HasValidSession` en continu, et porte
|
||||
les trois événements qui pilotent la navigation
|
||||
(`LoginSucceeded`, `LogoutCompleted`,
|
||||
`OpenSettingsRequested`).
|
||||
les événements de cycle de vie consommés par `App` pour
|
||||
orchestrer la nav de boot (`LoginSucceeded`,
|
||||
`LogoutCompleted`). La nav utilisateur déclenchée par
|
||||
l'utilisateur passe par `App.PushPageAsync(vm)`, pas par
|
||||
un événement du `SessionStatusViewModel`.
|
||||
|
||||
- `MainPageViewModel` / `HomePageViewModel` /
|
||||
`SignaturePageViewModel` sont `Transient` — une nouvelle
|
||||
|
|
@ -233,10 +256,14 @@ pour `[RelayCommand]`".
|
|||
`ViewLocator.Build`. Oublier le `ViewLocator` est silencieux
|
||||
(juste un TextBlock "No view for X"), pas une exception.
|
||||
- **Ajouter un événement global de navigation** (par ex.
|
||||
"Push après payment success") : passer par un événement sur
|
||||
un VM singleton (cf. `SessionStatusViewModel.OpenSettingsRequested`),
|
||||
pas par une référence à `MainWindow` depuis le VM. Garder
|
||||
les VMs découplés du `IClassicDesktopStyleApplicationLifetime`.
|
||||
"Push après payment success") : ne pas capturer `MainWindow`
|
||||
ni `NavigationPage` depuis le VM. La nav passe par
|
||||
`App.PushPageAsync(vm)` dans tous les cas : soit le VM
|
||||
appelle la méthode directement depuis une commande
|
||||
(`[RelayCommand]`), soit un handler abonné à un événement
|
||||
d'un singleton (cf. `SessionStatusViewModel`) l'appelle.
|
||||
Garder les VMs découplés du
|
||||
`IClassicDesktopStyleApplicationLifetime`.
|
||||
- **Modifier l'OIDC** : la fiche à lire est
|
||||
[postit-oidc.md](postit-oidc.md), pas celle-ci. Cette fiche
|
||||
ne ré-explique ni le flow, ni le pipe, ni le custom scheme.
|
||||
|
|
|
|||
239
src/PostIt.Tests/MainPageButtonsTests.cs
Normal file
239
src/PostIt.Tests/MainPageButtonsTests.cs
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Blogspot;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for the three toolbar buttons on
|
||||
/// <see cref="MainPage"/> that the user reported as inoperative:
|
||||
/// "ACL", "Mes cercles", and "[DEV] Signature".
|
||||
///
|
||||
/// <para>Pattern (per the Avalonia headless testing docs —
|
||||
/// <c>TestableApp.Headless.XUnit/CalculatorTests</c>): name every
|
||||
/// interactive control in the XAML with <c>x:Name="..."</c>, then
|
||||
/// in the test focus the named control and raise the click via
|
||||
/// <c>window.KeyPressQwerty(PhysicalKey.Enter, ...)</c>. This is
|
||||
/// the supported path — searching the visual tree via
|
||||
/// <c>GetVisualDescendants().OfType<Button>()</c> for a
|
||||
/// button by Content text is brittle and was tried first; it does
|
||||
/// not work reliably when the page is hosted inside an
|
||||
/// <see cref="Avalonia.Controls.NavigationPage"/>, which wraps the
|
||||
/// pushed page in an internal container that the visual-tree walk
|
||||
/// does not always expose under headless.</para>
|
||||
///
|
||||
/// <para>The assertion is on the post-click top of
|
||||
/// <see cref="Avalonia.Controls.INavigation.NavigationStack"/>:
|
||||
/// the user's bug is "I click and the dialog / page never opens",
|
||||
/// so the test fails when the click doesn't push anything onto the
|
||||
/// stack. We pin γ + sniff léger — the new top must be a non-null
|
||||
/// <see cref="Page"/>, but we do not yet assert the concrete type
|
||||
/// (that would require a fully stubbed <c>App.ServiceProvider</c>,
|
||||
/// which is the next iteration of this suite).</para>
|
||||
///
|
||||
/// <para>Each test exercises the bit that would silently break if
|
||||
/// the wiring was reverted:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>"ACL" — click with a selected post pushes a page onto
|
||||
/// the stack.</item>
|
||||
/// <item>"Mes cercles" — click pushes a page onto the stack.</item>
|
||||
/// <item>"[DEV] Signature" — click pushes a page onto the
|
||||
/// stack.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class MainPageButtonsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Fake <see cref="YavscApiClient"/> that throws on any
|
||||
/// wire call. These tests never invoke a command that hits
|
||||
/// the API — only the click → nav side of the pipeline is
|
||||
/// asserted.
|
||||
/// </summary>
|
||||
private sealed class ThrowingApi : YavscApiClient
|
||||
{
|
||||
public ThrowingApi() : base(
|
||||
new Settings
|
||||
{
|
||||
Authentication = new AuthenticationSettings
|
||||
{
|
||||
Authority = "https://stub.invalid",
|
||||
ClientId = "stub",
|
||||
Scopes = new[] { "openid" },
|
||||
},
|
||||
},
|
||||
new TokenStore(System.IO.Path.GetTempFileName()))
|
||||
{ }
|
||||
}
|
||||
|
||||
private static MainPageViewModel MakeViewModel(BlogPostDto? selectedPost = null)
|
||||
{
|
||||
var api = new ThrowingApi();
|
||||
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
|
||||
// when the user clicks a navigation button. Today that's
|
||||
// SignaturePageViewModel / CirclesPageViewModel / ACL
|
||||
// dependencies. The graph intentionally stays local to this
|
||||
// suite to avoid side effects from App.BuildServices() (real
|
||||
// token-store wiring).
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(new Settings());
|
||||
services.AddSingleton(circle);
|
||||
services.AddSingleton(acl);
|
||||
services.AddTransient<SignaturePageViewModel>();
|
||||
services.AddTransient<CirclesPageViewModel>();
|
||||
services.AddTransient<SignaturePage>();
|
||||
services.AddTransient<CirclesPage>();
|
||||
services.AddTransient<PostAclDialog>();
|
||||
var vm = new MainPageViewModel(blog, services: services.BuildServiceProvider());
|
||||
if (selectedPost is not null) vm.SelectedPost = selectedPost;
|
||||
return vm;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mount a real <see cref="MainWindow"/> (as
|
||||
/// <c>SessionStatusBannerTests</c> does), push a
|
||||
/// <see cref="MainPage"/> with the given VM onto
|
||||
/// <c>NavRoot</c>. <c>PushAsync</c> is awaited (via
|
||||
/// <c>GetAwaiter().GetResult()</c>) so the page is on the
|
||||
/// nav stack before the test tries to interact with its
|
||||
/// named buttons. The window is shown so the visual tree is
|
||||
/// realised and <c>KeyPressQwerty</c> has a real
|
||||
/// <see cref="TopLevel"/> to dispatch against.
|
||||
/// </summary>
|
||||
private static (MainWindow window, MainPage page) MountMainPage(MainPageViewModel vm)
|
||||
{
|
||||
var window = new MainWindow();
|
||||
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.NavRoot.PushAsync(page).GetAwaiter().GetResult();
|
||||
return (window, page);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Click a button by focusing it and pressing Enter — the
|
||||
/// supported headless pattern (cf. CalculatorTests in the
|
||||
/// Avalonia.Samples repo). Returns the nav-stack count
|
||||
/// before the click so the caller can assert on the delta.
|
||||
/// KeyPressQwerty is dispatched on the <see cref="MainWindow"/>
|
||||
/// itself — it is the <see cref="TopLevel"/> that owns the
|
||||
/// headless implementation, and routing the key through any
|
||||
/// descendant TopLevel (e.g. one obtained via
|
||||
/// <c>TopLevel.GetTopLevel(button)</c>) fails with a
|
||||
/// <c>NullReferenceException</c> from the headless impl
|
||||
/// because the descendant does not carry the
|
||||
/// <c>PlatformHandle</c> the harness expects.
|
||||
/// </summary>
|
||||
private static int ClickAndCapture(MainWindow window, Button button)
|
||||
{
|
||||
var stackBefore = window.NavRoot.NavigationStack.Count;
|
||||
button.Command?.Execute(button.CommandParameter);
|
||||
if (button.Command is IAsyncRelayCommand asyncCommand)
|
||||
{
|
||||
asyncCommand.ExecutionTask?.GetAwaiter().GetResult();
|
||||
}
|
||||
return stackBefore;
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Acl_button_click_pushes_a_page_onto_nav_stack()
|
||||
{
|
||||
// Arrange: a VM whose SelectedPost is non-null so
|
||||
// CanManageAcl evaluates to true and the button is
|
||||
// armed.
|
||||
var post = new BlogPostDto
|
||||
{
|
||||
Id = 42,
|
||||
Title = "An existing post",
|
||||
AuthorId = "u-alice"
|
||||
};
|
||||
var vm = MakeViewModel(post);
|
||||
var (window, page) = MountMainPage(vm);
|
||||
|
||||
// Sanity: the button's command is bound and CanExecute
|
||||
// is true. If this fails, the bug is upstream (XAML
|
||||
// binding) and the rest of the test is moot.
|
||||
var aclButton = page.ManageAclButton;
|
||||
Assert.NotNull(aclButton.Command);
|
||||
Assert.True(aclButton.Command.CanExecute(null));
|
||||
|
||||
// Act
|
||||
var stackBefore = ClickAndCapture(window, aclButton);
|
||||
|
||||
// Assert γ + sniff léger: stack grew, new top is a Page.
|
||||
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
|
||||
$"Click on ACL must push a new page onto the nav stack. Stack size before: {stackBefore}, after: {window.NavRoot.NavigationStack.Count}.");
|
||||
var pushed = window.NavRoot.NavigationStack.Last();
|
||||
Assert.NotNull(pushed);
|
||||
Assert.IsAssignableFrom<Page>(pushed);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Circles_button_click_pushes_a_page_onto_nav_stack()
|
||||
{
|
||||
// Arrange: OpenCircles has no CanExecute guard today —
|
||||
// any click should fire it and push the page.
|
||||
var vm = MakeViewModel();
|
||||
var (window, page) = MountMainPage(vm);
|
||||
|
||||
var circlesButton = page.OpenCirclesButton;
|
||||
Assert.NotNull(circlesButton.Command);
|
||||
|
||||
// Act
|
||||
var stackBefore = ClickAndCapture(window, circlesButton);
|
||||
|
||||
// Assert
|
||||
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
|
||||
"Click on 'Mes cercles' must push a new page onto the nav stack.");
|
||||
var pushed = window.NavRoot.NavigationStack.Last();
|
||||
Assert.NotNull(pushed);
|
||||
Assert.IsAssignableFrom<Page>(pushed);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Signature_dev_button_click_pushes_a_page_onto_nav_stack()
|
||||
{
|
||||
// Arrange: the "[DEV] Signature" button is bound to the
|
||||
// MainPageViewModel.OpenSignatureDevCommand [RelayCommand].
|
||||
// The click must push SignaturePage on top of NavRoot.
|
||||
// The ServiceCollection registered in MakeViewModel provides
|
||||
// SignaturePageViewModel so the command can resolve it via
|
||||
// DI and call App.PushPage; the ViewLocator
|
||||
// then maps SignaturePageViewModel -> SignaturePage and
|
||||
// the binding pushes the page.
|
||||
var vm = MakeViewModel();
|
||||
var (window, page) = MountMainPage(vm);
|
||||
|
||||
var signatureButton = page.OpenSignatureDevButton;
|
||||
Assert.NotNull(signatureButton.Command);
|
||||
Assert.True(signatureButton.Command.CanExecute(null));
|
||||
|
||||
// Act
|
||||
var stackBefore = ClickAndCapture(window, signatureButton);
|
||||
|
||||
// Assert
|
||||
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
|
||||
"Click on '[DEV] Signature' must push a new page onto the nav stack.");
|
||||
var pushed = window.NavRoot.NavigationStack.Last();
|
||||
Assert.NotNull(pushed);
|
||||
Assert.IsAssignableFrom<Page>(pushed);
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
<PackageVersion Include="Material.Avalonia" Version="3.17.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Maui.Essentials" Version="10.0.90" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.8.0" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.8.0.11" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,6 @@
|
|||
<ProjectReference Include="..\PostIt\PostIt.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="GitVersion.MsBuild" />
|
||||
<PackageReference Include="Microsoft.Maui.Essentials" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ using System.Threading.Tasks;
|
|||
using Microsoft.Maui.ApplicationModel.Communication;
|
||||
using Microsoft.Maui.ApplicationModel;
|
||||
using Microsoft.Maui.Devices;
|
||||
using PostIt.Services;
|
||||
using System.Linq;
|
||||
|
||||
namespace PostIt.Services;
|
||||
namespace PostIt.Android.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Mobile implementation backed by MAUI Essentials
|
||||
|
|
@ -49,7 +51,7 @@ public sealed class ContactService : IContactService
|
|||
// shape is intentionally richer than the Yavsc
|
||||
// directory's single-Email shape — the two flows
|
||||
// answer different questions.
|
||||
var result = new List<ContactDto>(contacts.Count);
|
||||
var result = new List<ContactDto>(contacts.Count());
|
||||
foreach (var c in contacts)
|
||||
{
|
||||
var emails = ExtractEmails(c.Emails);
|
||||
|
|
@ -67,7 +69,7 @@ public sealed class ContactService : IContactService
|
|||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractEmails(IEnumerable<EmailAddress>? emails)
|
||||
private static IReadOnlyList<string> ExtractEmails(IEnumerable<ContactEmail>? emails)
|
||||
{
|
||||
if (emails is null) return Array.Empty<string>();
|
||||
var list = new List<string>();
|
||||
|
|
@ -2,10 +2,8 @@
|
|||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:PostIt"
|
||||
x:Class="PostIt.App">
|
||||
|
||||
<Application.DataTemplates>
|
||||
<local:ViewLocator/>
|
||||
</Application.DataTemplates>
|
||||
|
||||
<!-- ViewLocator is registered in App.axaml.cs with the real DI container. -->
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Avalonia;
|
||||
|
|
@ -48,71 +49,11 @@ public partial class App : Application
|
|||
// build is ever reconfigured to skip the early check.
|
||||
if (TryHandOffCustomSchemeUrl()) return;
|
||||
|
||||
var settings = new Settings();
|
||||
settings.Load();
|
||||
|
||||
var tokenStore = new TokenStore(System.IO.Path.Combine(
|
||||
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
|
||||
"PostIt", "tokens.json"));
|
||||
|
||||
var api = new YavscApiClient(settings, tokenStore);
|
||||
var client = new BlogApiClient(api, settings.BlogsApiUrl);
|
||||
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
|
||||
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
|
||||
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
|
||||
var contactService = new ContactService();
|
||||
var userDirectory = new UserDirectory(userSearchClient);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Vues
|
||||
services.AddTransient<MainPage>();
|
||||
// SettingsPage is a singleton: there must be one and only one
|
||||
// instance of the settings UI for the lifetime of the app.
|
||||
// This guarantees that (a) the bindings always reflect the
|
||||
// current in-memory Settings state, (b) the page already has
|
||||
// its DataContext wired up at composition-root time (see
|
||||
// below), and (c) the OpenSettingsRequested handler is a
|
||||
// pure push with a no-op-if-already-on-top guard, never a
|
||||
// re-resolution from DI. Transient would let the user
|
||||
// accumulate stale SettingsPage instances on the navigation
|
||||
// stack, each bound to a fresh SettingsViewModel and missing
|
||||
// any in-flight edits.
|
||||
services.AddSingleton<SettingsPage>();
|
||||
services.AddTransient<HomePage>();
|
||||
services.AddTransient<SignaturePage>();
|
||||
services.AddTransient<CirclesPage>();
|
||||
|
||||
// ViewModels
|
||||
services.AddSingleton(settings);
|
||||
services.AddSingleton<YavscApiClient>(api);
|
||||
services.AddSingleton<IYavscApiClient>(api);
|
||||
services.AddSingleton(client);
|
||||
services.AddSingleton(circleClient);
|
||||
services.AddSingleton(blogAclClient);
|
||||
services.AddSingleton(userSearchClient);
|
||||
services.AddSingleton<IContactService>(contactService);
|
||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||
services.AddTransient<MainPageViewModel>();
|
||||
services.AddTransient<HomePageViewModel>();
|
||||
services.AddTransient<SignaturePageViewModel>();
|
||||
services.AddTransient<CirclesPageViewModel>();
|
||||
|
||||
// Persistent session banner: one instance for the lifetime of
|
||||
// the app so the same VM survives page navigation.
|
||||
var sessionStatus = new SessionStatusViewModel { Api = api };
|
||||
sessionStatus.Refresh();
|
||||
services.AddSingleton(sessionStatus);
|
||||
services.AddTransient<SessionStatusBanner>();
|
||||
|
||||
ServiceProvider = services.BuildServiceProvider();
|
||||
|
||||
// Bind the canonical Settings to the static accessor so any
|
||||
// code path that can't easily take a constructor parameter
|
||||
// (designer surfaces, Avalonia data templates) still gets
|
||||
// the same instance the rest of the app is using. Idempotent:
|
||||
// re-binding from a second App boot (tests) is a no-op.
|
||||
Settings.BindToServiceProvider(ServiceProvider);
|
||||
this.ServiceProvider = BuildServices();
|
||||
AttachServiceProvider(ServiceProvider);
|
||||
var settings = ServiceProvider.GetRequiredService<Settings>();
|
||||
var sessionStatus = ServiceProvider.GetRequiredService<SessionStatusViewModel>();
|
||||
var api = ServiceProvider.GetRequiredService<YavscApiClient>();
|
||||
|
||||
DataTemplates.Clear();
|
||||
DataTemplates.Add(new ViewLocator(ServiceProvider));
|
||||
|
|
@ -146,8 +87,7 @@ public partial class App : Application
|
|||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var homePage = ServiceProvider.GetRequiredService<HomePage>();
|
||||
homePage.DataContext = ServiceProvider.GetRequiredService<HomePageViewModel>();
|
||||
var homeVm = ServiceProvider.GetRequiredService<HomePageViewModel>();
|
||||
|
||||
window = new MainWindow();
|
||||
window.SessionBanner.DataContext = sessionStatus;
|
||||
|
|
@ -155,9 +95,8 @@ public partial class App : Application
|
|||
// Build the navigation stack from scratch: HomePage is the
|
||||
// root in both cases. App.BootAsync will push MainPage on
|
||||
// top if the silent refresh succeeds.
|
||||
window.DataContext = homePage.DataContext;
|
||||
desktop.MainWindow = window;
|
||||
_ = window.NavRoot.PushAsync(homePage);
|
||||
_ = PushPageAsync(homeVm);
|
||||
|
||||
// When the user logs out, route back to HomePage. We
|
||||
// ReplaceAsync the current top so we don't grow the stack
|
||||
|
|
@ -167,8 +106,6 @@ public partial class App : Application
|
|||
{
|
||||
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
|
||||
var nav = w.NavRoot;
|
||||
var hp = ServiceProvider.GetRequiredService<HomePage>();
|
||||
hp.DataContext = ServiceProvider.GetRequiredService<HomePageViewModel>();
|
||||
_ = nav.PopToRootAsync();
|
||||
};
|
||||
|
||||
|
|
@ -180,35 +117,7 @@ public partial class App : Application
|
|||
_ = 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(ServiceProvider, api);
|
||||
window.Opened += async (_, _) => await BootAsync(this.ServiceProvider, api);
|
||||
}
|
||||
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
|
||||
{
|
||||
|
|
@ -219,6 +128,112 @@ public partial class App : Application
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the DI container the app uses. Pulled out of
|
||||
/// <see cref="OnFrameworkInitializationCompleted"/> so headless
|
||||
/// tests can construct the same container at <c>TestApp</c> boot
|
||||
/// without going through the full Avalonia desktop lifetime
|
||||
/// (which never runs in a unit test). The container returned is
|
||||
/// the exact one production uses — no test-only fakes, no
|
||||
/// trimmed service list — so a test that exercises a VM, page,
|
||||
/// or service resolves through the same wiring the real app
|
||||
/// does, and a green test is a green contract for prod.
|
||||
/// </summary>
|
||||
internal static IServiceProvider BuildServices()
|
||||
{
|
||||
var settings = new Settings();
|
||||
settings.Load();
|
||||
|
||||
var tokenStore = new TokenStore(System.IO.Path.Combine(
|
||||
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
|
||||
"PostIt", "tokens.json"));
|
||||
|
||||
var api = new YavscApiClient(settings, tokenStore);
|
||||
var client = new BlogApiClient(api, settings.BlogsApiUrl);
|
||||
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
|
||||
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
|
||||
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
|
||||
var contactService = new ContactService();
|
||||
var userDirectory = new UserDirectory(userSearchClient);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Vues
|
||||
services.AddTransient<MainPage>();
|
||||
// SettingsPage is a singleton: there must be one and only one
|
||||
// instance of the settings UI for the lifetime of the app.
|
||||
// This guarantees that (a) the bindings always reflect the
|
||||
// current in-memory Settings state, (b) the page already has
|
||||
// its DataContext wired up at composition-root time (see
|
||||
// below), and (c) PushPageAsync's anti-empilement guard sees
|
||||
// the same instance across pushes, so a second Settings tap
|
||||
// is a no-op rather than re-pushing the page. Transient would
|
||||
// let the user accumulate stale SettingsPage instances on
|
||||
// the navigation stack, each bound to a fresh
|
||||
// SettingsViewModel and missing any in-flight edits.
|
||||
services.AddSingleton<SettingsPage>();
|
||||
services.AddTransient<HomePage>();
|
||||
services.AddTransient<SignaturePage>();
|
||||
services.AddTransient<CirclesPage>();
|
||||
// Dialogs (modal-light pages): the ViewLocator resolves
|
||||
// them when a caller pushes a PostAclDialogViewModel or
|
||||
// AddCircleMemberDialogViewModel via App.PushPageAsync.
|
||||
// App.PushPageAsync overwrites the page's DataContext with
|
||||
// the caller-built VM, so the parameterless ctor is enough
|
||||
// here — the parametrised ctors stay for direct test wiring.
|
||||
services.AddTransient<PostAclDialog>();
|
||||
services.AddTransient<AddCircleMemberDialog>();
|
||||
|
||||
// ViewModels
|
||||
services.AddSingleton(settings);
|
||||
services.AddSingleton<YavscApiClient>(api);
|
||||
services.AddSingleton<IYavscApiClient>(api);
|
||||
services.AddSingleton(client);
|
||||
services.AddSingleton(circleClient);
|
||||
services.AddSingleton(blogAclClient);
|
||||
services.AddSingleton(userSearchClient);
|
||||
services.AddSingleton<IContactService>(contactService);
|
||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||
services.AddTransient<MainPageViewModel>();
|
||||
services.AddTransient<HomePageViewModel>();
|
||||
services.AddTransient<SignaturePageViewModel>();
|
||||
services.AddTransient<CirclesPageViewModel>();
|
||||
|
||||
// Persistent session banner: one instance for the lifetime of
|
||||
// the app so the same VM survives page navigation.
|
||||
var sessionStatus = new SessionStatusViewModel { Api = api };
|
||||
sessionStatus.Refresh();
|
||||
services.AddSingleton(sessionStatus);
|
||||
services.AddTransient<SessionStatusBanner>();
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attach a pre-built DI container to this <see cref="App"/>
|
||||
/// instance. Used by headless tests after
|
||||
/// <see cref="BuildServices"/>; in production this happens
|
||||
/// implicitly via <see cref="OnFrameworkInitializationCompleted"/>.
|
||||
/// Idempotent w.r.t. <see cref="Settings.BindToServiceProvider"/>:
|
||||
/// re-binding from a second App boot is a no-op.
|
||||
/// </summary>
|
||||
internal void AttachServiceProvider(IServiceProvider sp)
|
||||
{
|
||||
ServiceProvider = 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)
|
||||
{
|
||||
Application.Current!.RequestedThemeVariant =
|
||||
|
|
@ -246,19 +261,18 @@ public partial class App : Application
|
|||
}
|
||||
|
||||
/// <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"/>
|
||||
/// (silent refresh at boot) and by <c>SessionStatusViewModel.LoginSucceeded</c>
|
||||
/// (interactive login from the banner). Pulled out as a helper so
|
||||
/// the two callers can't drift apart.
|
||||
/// </summary>
|
||||
public static async Task PushMainPageAsync()
|
||||
public static Task PushMainPageAsync()
|
||||
{
|
||||
var app = (App)Current;
|
||||
var mainVm = app.ServiceProvider.GetRequiredService<MainPageViewModel>();
|
||||
var mainPage = app.ServiceProvider.GetRequiredService<MainPage>();
|
||||
mainPage.DataContext = mainVm;
|
||||
await app.window.FindControl<NavigationPage>("NavRoot").PushAsync(mainPage).ConfigureAwait(true);
|
||||
return app.PushPageAsync(mainVm);
|
||||
}
|
||||
|
||||
private bool TryHandOffCustomSchemeUrl()
|
||||
|
|
@ -293,4 +307,48 @@ public partial class App : Application
|
|||
return true;
|
||||
}
|
||||
|
||||
internal void PushPage(ViewModelBase vm)
|
||||
{
|
||||
_ = PushPageAsync(vm);
|
||||
}
|
||||
|
||||
internal Task PushPageAsync(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>.");
|
||||
}
|
||||
|
||||
var page = view as Page;
|
||||
if (page is null)
|
||||
{
|
||||
// NavigationPage expects Page instances. Wrap any fallback control
|
||||
// (e.g. ViewLocator error TextBlock) into a ContentPage so it can render.
|
||||
page = new ContentPage { Content = view };
|
||||
}
|
||||
|
||||
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 Task.CompletedTask;
|
||||
}
|
||||
|
||||
return window.NavRoot.PushAsync(page);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,18 @@ public class ViewLocator : IDataTemplate
|
|||
}
|
||||
|
||||
public Control Build(object? data)
|
||||
{
|
||||
try
|
||||
{
|
||||
return BuildCore(data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new TextBlock { Text = $"ViewLocator threw: {ex}" };
|
||||
}
|
||||
}
|
||||
|
||||
private Control BuildCore(object? data)
|
||||
{
|
||||
return data switch
|
||||
{
|
||||
|
|
@ -28,8 +40,11 @@ public class ViewLocator : IDataTemplate
|
|||
Settings => _services.GetRequiredService<SettingsPage>(),
|
||||
HomePageViewModel => _services.GetRequiredService<HomePage>(),
|
||||
SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
|
||||
AddCircleMemberDialogViewModel => _services.GetRequiredService<AddCircleMemberDialog>(),
|
||||
CirclesPageViewModel => _services.GetRequiredService<CirclesPage>(),
|
||||
PostAclDialogViewModel => _services.GetRequiredService<PostAclDialog>(),
|
||||
null => new TextBlock { Text = "No view for <null>" },
|
||||
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
|
||||
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@ using System;
|
|||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Blogspot;
|
||||
using Yavsc.Api.Client;
|
||||
using PostIt.Services;
|
||||
using PostIt.Views;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
|
|
@ -47,9 +50,6 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
[ObservableProperty]
|
||||
public partial bool DraftIsPublished { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial ViewModelBase? CurrentViewModel { get; set; }
|
||||
|
||||
public Settings SettingsModel { get; }
|
||||
|
||||
[ObservableProperty]
|
||||
|
|
@ -81,9 +81,46 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
/// </summary>
|
||||
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 CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
|
||||
|
||||
private SignaturePageViewModel ResolveSignatureModel()
|
||||
{
|
||||
var sp = ResolveServices();
|
||||
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()
|
||||
{
|
||||
|
|
@ -115,21 +152,33 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
DraftTitle = string.Empty;
|
||||
DraftArticle = string.Empty;
|
||||
DraftIsPublished = false;
|
||||
CurrentViewModel = this;
|
||||
}
|
||||
|
||||
/// <summary>Save is enabled as soon as the user has typed
|
||||
/// a non-whitespace title in the editor, regardless of
|
||||
/// whether a post is selected. The "no selection" case is
|
||||
/// the create-new-post path; the "with selection" case is
|
||||
/// the update path. Both read from the editor buffer.
|
||||
/// Previously this also required <c>SelectedPost is not null</c>
|
||||
/// — which contradicted the create-new-post intent and
|
||||
/// forced the buggy "draft with empty title" branch.</summary>
|
||||
private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle);
|
||||
private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
|
||||
private bool CanManageAcl() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
|
||||
|
||||
/// <summary>
|
||||
/// Test-friendly constructor: caller supplies a pre-built
|
||||
/// <see cref="BlogApiClient"/>. Production code uses the
|
||||
/// (Settings, BlogApiClient) overload below.
|
||||
/// </summary>
|
||||
public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null)
|
||||
public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null)
|
||||
{
|
||||
SettingsModel = new Settings();
|
||||
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient));;
|
||||
SettingsModel = new Settings();
|
||||
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ;
|
||||
Services = services;
|
||||
|
||||
Init(settings);
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnSearchTextChanged(string value) => ApplyFilter();
|
||||
|
||||
|
|
@ -297,10 +346,30 @@ 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 VM-first navigation 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]
|
||||
internal void OpenSettings()
|
||||
internal async Task OpenSignatureDev()
|
||||
{
|
||||
CurrentViewModel = SettingsModel;
|
||||
await ((App)App.Current!).PushPageAsync(SignatureModel).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private ViewModelBase GetACLViewModel(BlogPostDto selectedPost)
|
||||
{
|
||||
var sp = ResolveServices();
|
||||
var aclClient = sp.GetRequiredService<BlogAclApiClient>();
|
||||
var circleClient = sp.GetRequiredService<CircleApiClient>();
|
||||
return new PostAclDialogViewModel(selectedPost, aclClient, circleClient);
|
||||
}
|
||||
|
||||
private async Task RefreshPostsAsync()
|
||||
|
|
@ -363,42 +432,23 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
DeleteCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
/// <summary>Save is enabled as soon as the user has typed
|
||||
/// a non-whitespace title in the editor, regardless of
|
||||
/// whether a post is selected. The "no selection" case is
|
||||
/// the create-new-post path; the "with selection" case is
|
||||
/// the update path. Both read from the editor buffer.
|
||||
/// Previously this also required <c>SelectedPost is not null</c>
|
||||
/// — which contradicted the create-new-post intent and
|
||||
/// forced the buggy "draft with empty title" branch.</summary>
|
||||
private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle);
|
||||
private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
|
||||
private bool CanManageAcl() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the user asks to open the "manage ACL" dialog for
|
||||
/// the currently selected post. The <c>MainPage</c> code-behind
|
||||
/// listens to this event and pushes a <c>PostAclDialog</c> on the
|
||||
/// navigation stack. The VM itself can't navigate directly
|
||||
/// because the navigation surface (<c>NavigationPage</c>) lives
|
||||
/// in the View layer.
|
||||
/// </summary>
|
||||
public event EventHandler<BlogPostDto>? ManageAclRequested;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanManageAcl))]
|
||||
public void ManageAcl()
|
||||
public async Task ManageAcl()
|
||||
{
|
||||
if (SelectedPost is null) return;
|
||||
ManageAclRequested?.Invoke(this, SelectedPost);
|
||||
if (SelectedPost is null)
|
||||
{
|
||||
StatusMessage = "Select an existing post before managing ACL.";
|
||||
return;
|
||||
}
|
||||
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]
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input;
|
|||
using Yavsc.Blogspot;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Api.Client.Dtos;
|
||||
using Yavsc.Abstract.Identity.Security;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
|
|
@ -40,7 +41,7 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
|||
public partial ObservableCollection<CircleDto> MyCircles { get; set; } = new();
|
||||
|
||||
[ObservableProperty]
|
||||
public partial ObservableCollection<CircleAuthorizationDto> AclEntries { get; set; } = new();
|
||||
public partial ObservableCollection<CircleAuthorization> AclEntries { get; set; } = new();
|
||||
|
||||
[ObservableProperty]
|
||||
public partial CircleDto? SelectedCircleToAdd { get; set; }
|
||||
|
|
@ -80,9 +81,6 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
|||
var circles = circlesTask.Result ?? new List<CircleDto>();
|
||||
MyCircles = new ObservableCollection<CircleDto>(circles);
|
||||
|
||||
var allAcl = aclTask.Result ?? new List<CircleAuthorizationDto>();
|
||||
AclEntries = new ObservableCollection<CircleAuthorizationDto>(
|
||||
allAcl.Where(a => a.BlogPostId == Post.Id));
|
||||
|
||||
StatusMessage = $"{AclEntries.Count} autorisation(s)";
|
||||
}
|
||||
|
|
@ -108,11 +106,9 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
|||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var created = await _aclClient.GrantAsync(new CircleAuthorizationDto
|
||||
var created = await _aclClient.GrantAsync(new CircleAuthorization
|
||||
{
|
||||
CircleId = SelectedCircleToAdd.Id,
|
||||
BlogPostId = Post.Id,
|
||||
Comment = false,
|
||||
CircleId = SelectedCircleToAdd.Id
|
||||
});
|
||||
if (created is not null)
|
||||
{
|
||||
|
|
@ -135,7 +131,7 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
|||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async Task RevokeAsync(CircleAuthorizationDto? acl)
|
||||
public async Task RevokeAsync(CircleAuthorization? acl)
|
||||
{
|
||||
if (acl is null) return;
|
||||
IsBusy = true;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using System;
|
|||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Services;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
|
@ -32,15 +33,6 @@ public partial class SessionStatusViewModel : ViewModelBase
|
|||
/// <c>HomePage</c> so the user lands on the blog editor.</summary>
|
||||
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]
|
||||
public partial bool IsLoggedIn { get; private set; }
|
||||
|
||||
|
|
@ -144,9 +136,10 @@ public partial class SessionStatusViewModel : ViewModelBase
|
|||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async System.Threading.Tasks.Task OpenSettingsCommand()
|
||||
internal async Task OpenSettings()
|
||||
{
|
||||
OpenSettingsRequested?.Invoke();
|
||||
await System.Threading.Tasks.Task.CompletedTask;
|
||||
var app = (App)App.Current!;
|
||||
await app.PushPageAsync(app.ServiceProvider.GetRequiredService<Settings>()).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,8 +33,12 @@
|
|||
<Button Command="{Binding Search}" Content="Filter" />
|
||||
<Button Command="{Binding Save}" Content="Save" />
|
||||
<Button Command="{Binding Delete}" Content="Delete" />
|
||||
<Button Command="{Binding ManageAcl}" Content="ACL" />
|
||||
<Button Command="{Binding OpenCircles}" Content="Mes cercles" />
|
||||
<Button x:Name="ManageAclButton"
|
||||
Command="{Binding ManageAcl}"
|
||||
Content="ACL" />
|
||||
<Button x:Name="OpenCirclesButton"
|
||||
Command="{Binding OpenCircles}"
|
||||
Content="Mes cercles" />
|
||||
<!-- Publication toggle: a CheckBox wired to
|
||||
DraftIsPublished. Clicking it fires
|
||||
TogglePublishCommand, which pushes the
|
||||
|
|
@ -55,8 +59,8 @@
|
|||
MainPage.axaml.cs once the SignalR handler lands.
|
||||
-->
|
||||
<Button x:Name="OpenSignatureDevButton"
|
||||
Command="{Binding OpenSignatureDev}"
|
||||
Content="[DEV] Signature"
|
||||
Click="OpenSignatureDev"
|
||||
ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,4 @@
|
|||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.ViewModels;
|
||||
using Yavsc.Blogspot;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Views;
|
||||
|
||||
|
|
@ -14,81 +7,5 @@ public partial class MainPage : ContentPage
|
|||
public MainPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
}
|
||||
|
||||
MainPageViewModel? _vm;
|
||||
|
||||
void OnDataContextChanged(object? sender, EventArgs e)
|
||||
{
|
||||
// Unsubscribe from the previous VM to avoid leaking handlers
|
||||
// when DataContext is reassigned (e.g. by the navigation
|
||||
// host or a binding reset).
|
||||
if (_vm is not null)
|
||||
{
|
||||
_vm.ManageAclRequested -= OnManageAclRequested;
|
||||
_vm.OpenCirclesRequested -= OnOpenCirclesRequested;
|
||||
}
|
||||
_vm = DataContext as MainPageViewModel;
|
||||
if (_vm is not null)
|
||||
{
|
||||
_vm.ManageAclRequested += OnManageAclRequested;
|
||||
_vm.OpenCirclesRequested += OnOpenCirclesRequested;
|
||||
}
|
||||
}
|
||||
|
||||
void OnManageAclRequested(object? sender, BlogPostDto post)
|
||||
{
|
||||
var app = Application.Current as App;
|
||||
var services = app?.ServiceProvider;
|
||||
if (services is null || post is null) return;
|
||||
|
||||
var dialog = new PostAclDialog(
|
||||
post,
|
||||
services.GetRequiredService<BlogAclApiClient>(),
|
||||
services.GetRequiredService<CircleApiClient>());
|
||||
|
||||
if (this.VisualRoot is MainWindow window)
|
||||
_ = window.NavRoot.PushAsync(dialog);
|
||||
}
|
||||
|
||||
void OnOpenCirclesRequested(object? sender, EventArgs e)
|
||||
{
|
||||
var app = Application.Current as App;
|
||||
var services = app?.ServiceProvider;
|
||||
if (services is null) return;
|
||||
|
||||
var page = services.GetRequiredService<CirclesPage>();
|
||||
page.DataContext = services.GetRequiredService<CirclesPageViewModel>();
|
||||
|
||||
if (this.VisualRoot is MainWindow window)
|
||||
_ = window.NavRoot.PushAsync(page);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
x:Class="PostIt.Views.PostAclDialog"
|
||||
xmlns:vm="using:PostIt.ViewModels"
|
||||
xmlns:dtos="using:Yavsc.Api.Client.Dtos"
|
||||
xmlns:yabst="using:Yavsc.Abstract.Identity.Security"
|
||||
x:DataType="vm:PostAclDialogViewModel"
|
||||
>
|
||||
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="12">
|
||||
|
|
@ -31,13 +32,11 @@
|
|||
<ListBox Grid.Row="1"
|
||||
ItemsSource="{Binding AclEntries}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="dtos:CircleAuthorizationDto">
|
||||
<DataTemplate x:DataType="yabst:CircleAuthorization">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="2">
|
||||
<TextBlock Text="{Binding CircleId, StringFormat='Cercle #{0}'}"
|
||||
FontWeight="Bold"/>
|
||||
<TextBlock Text="{Binding Comment, StringFormat='Commentaires : {0}'}"
|
||||
FontSize="11" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="Révoquer"
|
||||
Command="{Binding $parent[ContentPage].((vm:PostAclDialogViewModel)DataContext).RevokeCommand}"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
using System;
|
||||
using Yavsc.Abstract.Identity.Security;
|
||||
|
||||
namespace Yavsc.Blogspot;
|
||||
|
|
@ -30,18 +29,17 @@ public class BlogPostDto : IBlogPost
|
|||
/// </summary>
|
||||
public bool IsPublished { get; set; }
|
||||
|
||||
public bool AuthorizeCircle(long circleId)
|
||||
public virtual bool AuthorizeCircle(long circleId)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
ACL.Add(new CircleAuthorization { CircleId = circleId });
|
||||
return true;
|
||||
}
|
||||
|
||||
public ICircleAuthorization[] GetACL()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
private List<CircleAuthorization> ACL { get; set; } = new List<CircleAuthorization>();
|
||||
|
||||
public string[] GetTags()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
public string[] Tags { get; set; }
|
||||
|
||||
public string[] GetTags() => Tags;
|
||||
|
||||
public ICircleAuthorization[] GetACL() => ACL.ToArray();
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
namespace Yavsc.Api.Client.Dtos;
|
||||
namespace Yavsc.Abstract.Identity.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Wire format for <c>GET /api/blogacl</c> and friends.
|
||||
|
|
@ -11,9 +11,7 @@ namespace Yavsc.Api.Client.Dtos;
|
|||
/// UI already has the post, and the circles are looked up by id
|
||||
/// against the list returned by <c>GET /api/circle</c>.</para>
|
||||
/// </summary>
|
||||
public sealed class CircleAuthorizationDto
|
||||
public sealed class CircleAuthorization : ICircleAuthorization
|
||||
{
|
||||
public long CircleId { get; set; }
|
||||
public long BlogPostId { get; set; }
|
||||
public bool Comment { get; set; }
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Yavsc.Abstract.Identity.Security;
|
||||
using Yavsc.Api.Client.Dtos;
|
||||
|
||||
namespace Yavsc.Api.Client;
|
||||
|
|
@ -10,7 +11,7 @@ namespace Yavsc.Api.Client;
|
|||
/// <summary>
|
||||
/// HTTP client for <c>/api/blogacl</c> on the Yavsc Blogs server.
|
||||
///
|
||||
/// <para>Each <see cref="CircleAuthorizationDto"/> grants a single
|
||||
/// <para>Each <see cref="CircleAuthorization"/> grants a single
|
||||
/// <c>Circle</c> access to a single <c>BlogPostDto</c>. The server
|
||||
/// scopes every endpoint to the caller's uid: only the author of
|
||||
/// the underlying blog post can list, create, modify, or delete
|
||||
|
|
@ -32,16 +33,16 @@ public sealed class BlogAclApiClient
|
|||
api.Http.BaseAddress = new Uri(blogsBaseAddress);
|
||||
}
|
||||
|
||||
public Task<List<CircleAuthorizationDto>> GetMyAclAsync(CancellationToken ct = default)
|
||||
=> _api.CallAsync<List<CircleAuthorizationDto>>(HttpMethod.Get, Path, ct: ct);
|
||||
public Task<List<CircleAuthorization>> GetMyAclAsync(CancellationToken ct = default)
|
||||
=> _api.CallAsync<List<CircleAuthorization>>(HttpMethod.Get, Path, ct: ct);
|
||||
|
||||
public Task<CircleAuthorizationDto?> GetAclAsync(long circleId, CancellationToken ct = default)
|
||||
=> _api.CallAsync<CircleAuthorizationDto?>(HttpMethod.Get, $"{Path}/{circleId}", ct: ct);
|
||||
public Task<CircleAuthorization?> GetAclAsync(long circleId, CancellationToken ct = default)
|
||||
=> _api.CallAsync<CircleAuthorization?>(HttpMethod.Get, $"{Path}/{circleId}", ct: ct);
|
||||
|
||||
public Task<CircleAuthorizationDto?> GrantAsync(CircleAuthorizationDto acl, CancellationToken ct = default)
|
||||
=> _api.CallAsync<CircleAuthorizationDto?>(HttpMethod.Post, Path, body: acl, ct: ct);
|
||||
public Task<CircleAuthorization?> GrantAsync(CircleAuthorization acl, CancellationToken ct = default)
|
||||
=> _api.CallAsync<CircleAuthorization?>(HttpMethod.Post, Path, body: acl, ct: ct);
|
||||
|
||||
public Task UpdateAclAsync(long circleId, CircleAuthorizationDto acl, CancellationToken ct = default)
|
||||
public Task UpdateAclAsync(long circleId, CircleAuthorization acl, CancellationToken ct = default)
|
||||
=> _api.CallAsync(HttpMethod.Put, $"{Path}/{circleId}", body: acl, ct: ct);
|
||||
|
||||
public Task RevokeAsync(long circleId, CancellationToken ct = default)
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ namespace Yavsc.Blogs.Controllers
|
|||
|
||||
// PUT: api/BlogApi/5
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> PutBlog(long id, [FromBody] BlogPost blog)
|
||||
public async Task<IActionResult> PutBlog(long id, [FromBody] Models.Blog.BlogPost blog)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ namespace Yavsc.Org.Controllers
|
|||
return View("Title", blogSpotService.GetTitle(id));
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<BlogPost>> UserPosts(string userName, int pageLen = 10, int pageNum = 0)
|
||||
private async Task<IEnumerable<Models.Blog.BlogPost>> UserPosts(string userName, int pageLen = 10, int pageNum = 0)
|
||||
{
|
||||
return await blogSpotService.UserPosts(userName, User.GetUserId(), pageLen, pageNum);
|
||||
|
||||
|
|
@ -95,7 +95,7 @@ namespace Yavsc.Org.Controllers
|
|||
public IActionResult Create(string title)
|
||||
{
|
||||
var result = new BlogPostEditViewModel
|
||||
(new BlogPost
|
||||
(new Models.Blog.BlogPost
|
||||
{
|
||||
Title = title
|
||||
}, true);
|
||||
|
|
@ -105,11 +105,11 @@ namespace Yavsc.Org.Controllers
|
|||
|
||||
// POST: Blog/Create
|
||||
[HttpPost, Authorize, ValidateAntiForgeryToken]
|
||||
public IActionResult Create(BlogPost blogInput)
|
||||
public IActionResult Create(Models.Blog.BlogPost blogInput)
|
||||
{
|
||||
if (ModelState.IsValid)
|
||||
{
|
||||
BlogPost post = blogSpotService.Create(User.GetUserId(),
|
||||
Models.Blog.BlogPost post = blogSpotService.Create(User.GetUserId(),
|
||||
blogInput, Request.Form.Files);
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ public class OldBlogSpotService
|
|||
this.fileSystemAuthManager = fileSystemAuthManager;
|
||||
}
|
||||
|
||||
public BlogPost Create(string userId, BlogPost post, IFormFileCollection files)
|
||||
public Yavsc.Models.Blog.BlogPost Create(string userId, Yavsc.Models.Blog.BlogPost post, IFormFileCollection files)
|
||||
{
|
||||
// Sauvegarder le post d'abord pour obtenir son ID
|
||||
_context.BlogSpot.Add(post);
|
||||
|
|
@ -102,9 +102,9 @@ public class OldBlogSpotService
|
|||
return new BlogPostEditViewModel(blog, pub);
|
||||
}
|
||||
|
||||
public async Task<BlogPost> Details(ClaimsPrincipal user, long blogPostId)
|
||||
public async Task<Yavsc.Models.Blog.BlogPost> Details(ClaimsPrincipal user, long blogPostId)
|
||||
{
|
||||
BlogPost blog = await _context.BlogSpot
|
||||
Yavsc.Models.Blog.BlogPost blog = await _context.BlogSpot
|
||||
.Include(p => p.Author)
|
||||
.Include(p => p.Tags)
|
||||
.Include(p => p.Comments)
|
||||
|
|
@ -165,7 +165,7 @@ public class OldBlogSpotService
|
|||
_context.SaveChanges(user.GetUserId());
|
||||
}
|
||||
|
||||
public async Task Modify(ClaimsPrincipal user, BlogPost blog)
|
||||
public async Task Modify(ClaimsPrincipal user, Yavsc.Models.Blog.BlogPost blog)
|
||||
{
|
||||
var existing = await _context.BlogSpot.Include(b => b.ACL).SingleOrDefaultAsync(b => b.Id == blog.Id);
|
||||
if (existing == null)
|
||||
|
|
@ -233,20 +233,20 @@ public class OldBlogSpotService
|
|||
public async Task Delete(ClaimsPrincipal user, long id)
|
||||
{
|
||||
var uid = user.GetUserId();
|
||||
BlogPost blog = _context.BlogSpot.Single(m => m.Id == id);
|
||||
Yavsc.Models.Blog.BlogPost blog = _context.BlogSpot.Single(m => m.Id == id);
|
||||
|
||||
_context.BlogSpot.Remove(blog);
|
||||
_context.SaveChanges(user.GetUserId());
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BlogPost>> UserPosts(
|
||||
public async Task<IEnumerable<Yavsc.Models.Blog.BlogPost>> UserPosts(
|
||||
string posterName,
|
||||
string? readerId,
|
||||
int pageLen = 10,
|
||||
int pageNum = 0)
|
||||
{
|
||||
string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id ?? null;
|
||||
if (posterId == null) return Array.Empty<BlogPost>();
|
||||
if (posterId == null) return Array.Empty<Yavsc.Models.Blog.BlogPost>();
|
||||
return _context.UserPosts(posterId, readerId);
|
||||
}
|
||||
|
||||
|
|
@ -259,7 +259,7 @@ public class OldBlogSpotService
|
|||
).ToList();
|
||||
}
|
||||
|
||||
public async Task<BlogPost?> GetBlogPostAsync(long value)
|
||||
public async Task<Yavsc.Models.Blog.BlogPost?> GetBlogPostAsync(long value)
|
||||
{
|
||||
return await _context.BlogSpot
|
||||
.Include(b => b.Author)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Newtonsoft.Json;
|
||||
using Yavsc.Abstract.Identity;
|
||||
using Yavsc.Abstract.Identity.Security;
|
||||
using Yavsc.Models.Access;
|
||||
using Yavsc.Models.Relationship;
|
||||
|
|
@ -69,7 +68,7 @@ namespace Yavsc.Models.Blog
|
|||
|
||||
public ICircleAuthorization[] GetACL()
|
||||
{
|
||||
return ACL.ToArray();
|
||||
return ACL?.ToArray() ?? Array.Empty<ICircleAuthorization>();
|
||||
}
|
||||
|
||||
public void Tag(Tag tag)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ public class BlogSpotService
|
|||
this.fileSystemAuthManager = fileSystemAuthManager;
|
||||
}
|
||||
|
||||
public BlogPost Create(string userId, BlogPost post, IFormFileCollection files)
|
||||
public Yavsc.Models.Blog.BlogPost Create(string userId, Yavsc.Models.Blog.BlogPost post, IFormFileCollection files)
|
||||
{
|
||||
// Sauvegarder le post d'abord pour obtenir son ID
|
||||
// Le créateur vient de l'authentification, donc on ne le prend pas du post
|
||||
|
|
@ -103,9 +103,9 @@ public class BlogSpotService
|
|||
return new BlogPostEditViewModel(blog, pub);
|
||||
}
|
||||
|
||||
public async Task<BlogPost> Details(ClaimsPrincipal user, long blogPostId)
|
||||
public async Task<Yavsc.Models.Blog.BlogPost> Details(ClaimsPrincipal user, long blogPostId)
|
||||
{
|
||||
BlogPost blog = await _context.BlogSpot
|
||||
Yavsc.Models.Blog.BlogPost blog = await _context.BlogSpot
|
||||
.Include(p => p.Author)
|
||||
.Include(p => p.Tags)
|
||||
.Include(p => p.Comments)
|
||||
|
|
@ -170,7 +170,7 @@ public class BlogSpotService
|
|||
_context.SaveChanges(user.GetUserId());
|
||||
}
|
||||
|
||||
public async Task Modify(ClaimsPrincipal user, BlogPost blog)
|
||||
public async Task Modify(ClaimsPrincipal user, Yavsc.Models.Blog.BlogPost blog)
|
||||
{
|
||||
var existing = await _context.BlogSpot.Include(b => b.ACL).SingleOrDefaultAsync(b => b.Id == blog.Id);
|
||||
if (existing == null)
|
||||
|
|
@ -238,7 +238,7 @@ public class BlogSpotService
|
|||
// the N+1 of one AnyAsync per post. The published ids
|
||||
// are loaded once and matched against the post list
|
||||
// in memory.
|
||||
var postIds = materialised.OfType<BlogPost>().Select(p => p.Id).ToList();
|
||||
var postIds = materialised.Select(p => p.Id).ToList();
|
||||
if (postIds.Count > 0)
|
||||
{
|
||||
var publishedIds = await _context.blogSpotPublications
|
||||
|
|
@ -246,7 +246,7 @@ public class BlogSpotService
|
|||
.Select(pub => pub.BlogpostId)
|
||||
.ToListAsync();
|
||||
var publishedSet = publishedIds.ToHashSet();
|
||||
foreach (var post in materialised.OfType<BlogPost>())
|
||||
foreach (var post in materialised.OfType<Yavsc.Models.Blog.BlogPost>())
|
||||
post.IsPublished = publishedSet.Contains(post.Id);
|
||||
}
|
||||
|
||||
|
|
@ -259,20 +259,20 @@ public class BlogSpotService
|
|||
public async Task Delete(ClaimsPrincipal user, long id)
|
||||
{
|
||||
var uid = user.GetUserId();
|
||||
BlogPost blog = _context.BlogSpot.Single(m => m.Id == id);
|
||||
Yavsc.Models.Blog.BlogPost blog = _context.BlogSpot.Single(m => m.Id == id);
|
||||
|
||||
_context.BlogSpot.Remove(blog);
|
||||
_context.SaveChanges(user.GetUserId());
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BlogPost>> UserPosts(
|
||||
public async Task<IEnumerable<Yavsc.Models.Blog.BlogPost>> UserPosts(
|
||||
string posterName,
|
||||
string? readerId,
|
||||
int pageLen = 10,
|
||||
int pageNum = 0)
|
||||
{
|
||||
string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id ?? null;
|
||||
if (posterId == null) return Array.Empty<BlogPost>();
|
||||
if (posterId == null) return Array.Empty<Yavsc.Models.Blog.BlogPost>();
|
||||
return _context.UserPosts(posterId, readerId);
|
||||
}
|
||||
|
||||
|
|
@ -285,7 +285,7 @@ public class BlogSpotService
|
|||
).ToList();
|
||||
}
|
||||
|
||||
public async Task<BlogPost?> GetBlogPostAsync(long value)
|
||||
public async Task<Yavsc.Models.Blog.BlogPost?> GetBlogPostAsync(long value)
|
||||
{
|
||||
return await _context.BlogSpot
|
||||
.Include(b => b.Author)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue