Compare commits
3 commits
f36679aa65
...
aa098daed5
| Author | SHA1 | Date | |
|---|---|---|---|
|
aa098daed5 |
|||
|
e35bc273a3 |
|||
|
84f3ffa9c2 |
20 changed files with 409 additions and 205 deletions
|
|
@ -64,6 +64,25 @@ Quelques règles non capturées par `.editorconfig` :
|
||||||
- Préférer les types BCL (`int`, `string`) aux types framework
|
- Préférer les types BCL (`int`, `string`) aux types framework
|
||||||
(`Int32`, `String`).
|
(`Int32`, `String`).
|
||||||
- Préférer les expressions de pattern matching aux casts explicites.
|
- Préférer les expressions de pattern matching aux casts explicites.
|
||||||
|
- **Navigation (PostIt)** : la navigation est contrôlée par
|
||||||
|
`src/PostIt/PostIt/ViewLocator.cs`. Pour ouvrir un écran,
|
||||||
|
on affecte le ViewModel cible à la propriété `CurrentViewModel`
|
||||||
|
du `MainPageViewModel` (qui binde l'`IContentControl.Content`
|
||||||
|
de la page hôte). Tant que la vue correspondante est supportée
|
||||||
|
par le `ViewLocator`, ce dernier décide de l'instance de
|
||||||
|
`Control` à pousser en navigation, et il l'obtient de la DI
|
||||||
|
(`_services.GetRequiredService<TView>()`). 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. Exemple canonique :
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[RelayCommand]
|
||||||
|
internal void OpenSettings()
|
||||||
|
{
|
||||||
|
CurrentViewModel = SettingsModel;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Branches & commits
|
## Branches & commits
|
||||||
|
|
||||||
|
|
|
||||||
207
src/PostIt.Tests/MainPageButtonsTests.cs
Normal file
207
src/PostIt.Tests/MainPageButtonsTests.cs
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Headless;
|
||||||
|
using Avalonia.Headless.XUnit;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
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 vm = new MainPageViewModel(blog);
|
||||||
|
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 };
|
||||||
|
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.Focus();
|
||||||
|
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||||
|
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 uses XAML's
|
||||||
|
// Click="OpenSignatureDev" attribute, so we don't bind
|
||||||
|
// a Command here — we drive the click directly. The
|
||||||
|
// handler resolves App.ServiceProvider, which is null
|
||||||
|
// in a unit test, and early-returns; that is the
|
||||||
|
// failure mode the test pins.
|
||||||
|
var vm = MakeViewModel();
|
||||||
|
var (window, page) = MountMainPage(vm);
|
||||||
|
|
||||||
|
var signatureButton = page.OpenSignatureDevButton;
|
||||||
|
|
||||||
|
// 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="Material.Avalonia" Version="3.17.0" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.Maui.Essentials" Version="10.0.90" />
|
<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" />
|
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
@ -31,6 +31,6 @@
|
||||||
<ProjectReference Include="..\PostIt\PostIt.csproj" />
|
<ProjectReference Include="..\PostIt\PostIt.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="GitVersion.MsBuild" />
|
<PackageReference Include="Microsoft.Maui.Essentials" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
@ -6,8 +6,10 @@ using System.Threading.Tasks;
|
||||||
using Microsoft.Maui.ApplicationModel.Communication;
|
using Microsoft.Maui.ApplicationModel.Communication;
|
||||||
using Microsoft.Maui.ApplicationModel;
|
using Microsoft.Maui.ApplicationModel;
|
||||||
using Microsoft.Maui.Devices;
|
using Microsoft.Maui.Devices;
|
||||||
|
using PostIt.Services;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
namespace PostIt.Services;
|
namespace PostIt.Android.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Mobile implementation backed by MAUI Essentials
|
/// Mobile implementation backed by MAUI Essentials
|
||||||
|
|
@ -49,7 +51,7 @@ public sealed class ContactService : IContactService
|
||||||
// shape is intentionally richer than the Yavsc
|
// shape is intentionally richer than the Yavsc
|
||||||
// directory's single-Email shape — the two flows
|
// directory's single-Email shape — the two flows
|
||||||
// answer different questions.
|
// answer different questions.
|
||||||
var result = new List<ContactDto>(contacts.Count);
|
var result = new List<ContactDto>(contacts.Count());
|
||||||
foreach (var c in contacts)
|
foreach (var c in contacts)
|
||||||
{
|
{
|
||||||
var emails = ExtractEmails(c.Emails);
|
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>();
|
if (emails is null) return Array.Empty<string>();
|
||||||
var list = new List<string>();
|
var list = new List<string>();
|
||||||
|
|
@ -48,71 +48,11 @@ public partial class App : Application
|
||||||
// build is ever reconfigured to skip the early check.
|
// build is ever reconfigured to skip the early check.
|
||||||
if (TryHandOffCustomSchemeUrl()) return;
|
if (TryHandOffCustomSchemeUrl()) return;
|
||||||
|
|
||||||
var settings = new Settings();
|
var serviceProvider = BuildServices();
|
||||||
settings.Load();
|
AttachServiceProvider(serviceProvider);
|
||||||
|
var settings = serviceProvider.GetRequiredService<Settings>();
|
||||||
var tokenStore = new TokenStore(System.IO.Path.Combine(
|
var sessionStatus = serviceProvider.GetRequiredService<SessionStatusViewModel>();
|
||||||
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
|
var api = serviceProvider.GetRequiredService<YavscApiClient>();
|
||||||
"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);
|
|
||||||
|
|
||||||
DataTemplates.Clear();
|
DataTemplates.Clear();
|
||||||
DataTemplates.Add(new ViewLocator(ServiceProvider));
|
DataTemplates.Add(new ViewLocator(ServiceProvider));
|
||||||
|
|
@ -219,6 +159,93 @@ 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) 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>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
private static void ApplyDarkMode(Settings settings)
|
private static void ApplyDarkMode(Settings settings)
|
||||||
{
|
{
|
||||||
Application.Current!.RequestedThemeVariant =
|
Application.Current!.RequestedThemeVariant =
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ public class ViewLocator : IDataTemplate
|
||||||
Settings => _services.GetRequiredService<SettingsPage>(),
|
Settings => _services.GetRequiredService<SettingsPage>(),
|
||||||
HomePageViewModel => _services.GetRequiredService<HomePage>(),
|
HomePageViewModel => _services.GetRequiredService<HomePage>(),
|
||||||
SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
|
SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
|
||||||
|
AddCircleMemberDialogViewModel => _services.GetRequiredService<AddCircleMemberDialog>(),
|
||||||
|
CirclesPageViewModel => _services.GetRequiredService<CirclesPage>(),
|
||||||
|
PostAclDialogViewModel => _services.GetRequiredService<PostAclDialog>(),
|
||||||
null => new TextBlock { Text = "No view for <null>" },
|
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}" }
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,18 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
CurrentViewModel = this;
|
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>
|
/// <summary>
|
||||||
/// Test-friendly constructor: caller supplies a pre-built
|
/// Test-friendly constructor: caller supplies a pre-built
|
||||||
/// <see cref="BlogApiClient"/>. Production code uses the
|
/// <see cref="BlogApiClient"/>. Production code uses the
|
||||||
|
|
@ -125,11 +137,11 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null)
|
public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null)
|
||||||
{
|
{
|
||||||
SettingsModel = new Settings();
|
SettingsModel = new Settings();
|
||||||
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient));;
|
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ;
|
||||||
|
|
||||||
Init(settings);
|
Init(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnSearchTextChanged(string value) => ApplyFilter();
|
partial void OnSearchTextChanged(string value) => ApplyFilter();
|
||||||
|
|
||||||
|
|
@ -303,6 +315,11 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
CurrentViewModel = SettingsModel;
|
CurrentViewModel = SettingsModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ViewModelBase? GetACLViewModel(BlogPostDto selectedPost)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task RefreshPostsAsync()
|
private async Task RefreshPostsAsync()
|
||||||
{
|
{
|
||||||
var posts = await BlogClient.GetPostsAsync();
|
var posts = await BlogClient.GetPostsAsync();
|
||||||
|
|
@ -363,33 +380,13 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
DeleteCommand.NotifyCanExecuteChanged();
|
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))]
|
[RelayCommand(CanExecute = nameof(CanManageAcl))]
|
||||||
public void ManageAcl()
|
public void ManageAcl()
|
||||||
{
|
{
|
||||||
if (SelectedPost is null) return;
|
if (SelectedPost is null) return;
|
||||||
ManageAclRequested?.Invoke(this, SelectedPost);
|
CurrentViewModel = GetACLViewModel(SelectedPost);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input;
|
||||||
using Yavsc.Blogspot;
|
using Yavsc.Blogspot;
|
||||||
using Yavsc.Api.Client;
|
using Yavsc.Api.Client;
|
||||||
using Yavsc.Api.Client.Dtos;
|
using Yavsc.Api.Client.Dtos;
|
||||||
|
using Yavsc.Abstract.Identity.Security;
|
||||||
|
|
||||||
namespace PostIt.ViewModels;
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
|
|
@ -40,7 +41,7 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
||||||
public partial ObservableCollection<CircleDto> MyCircles { get; set; } = new();
|
public partial ObservableCollection<CircleDto> MyCircles { get; set; } = new();
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial ObservableCollection<CircleAuthorizationDto> AclEntries { get; set; } = new();
|
public partial ObservableCollection<CircleAuthorization> AclEntries { get; set; } = new();
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial CircleDto? SelectedCircleToAdd { get; set; }
|
public partial CircleDto? SelectedCircleToAdd { get; set; }
|
||||||
|
|
@ -80,9 +81,6 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
||||||
var circles = circlesTask.Result ?? new List<CircleDto>();
|
var circles = circlesTask.Result ?? new List<CircleDto>();
|
||||||
MyCircles = new ObservableCollection<CircleDto>(circles);
|
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)";
|
StatusMessage = $"{AclEntries.Count} autorisation(s)";
|
||||||
}
|
}
|
||||||
|
|
@ -108,11 +106,9 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var created = await _aclClient.GrantAsync(new CircleAuthorizationDto
|
var created = await _aclClient.GrantAsync(new CircleAuthorization
|
||||||
{
|
{
|
||||||
CircleId = SelectedCircleToAdd.Id,
|
CircleId = SelectedCircleToAdd.Id
|
||||||
BlogPostId = Post.Id,
|
|
||||||
Comment = false,
|
|
||||||
});
|
});
|
||||||
if (created is not null)
|
if (created is not null)
|
||||||
{
|
{
|
||||||
|
|
@ -135,7 +131,7 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public async Task RevokeAsync(CircleAuthorizationDto? acl)
|
public async Task RevokeAsync(CircleAuthorization? acl)
|
||||||
{
|
{
|
||||||
if (acl is null) return;
|
if (acl is null) return;
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,12 @@
|
||||||
<Button Command="{Binding Search}" Content="Filter" />
|
<Button Command="{Binding Search}" Content="Filter" />
|
||||||
<Button Command="{Binding Save}" Content="Save" />
|
<Button Command="{Binding Save}" Content="Save" />
|
||||||
<Button Command="{Binding Delete}" Content="Delete" />
|
<Button Command="{Binding Delete}" Content="Delete" />
|
||||||
<Button Command="{Binding ManageAcl}" Content="ACL" />
|
<Button x:Name="ManageAclButton"
|
||||||
<Button Command="{Binding OpenCircles}" Content="Mes cercles" />
|
Command="{Binding ManageAcl}"
|
||||||
|
Content="ACL" />
|
||||||
|
<Button x:Name="OpenCirclesButton"
|
||||||
|
Command="{Binding OpenCircles}"
|
||||||
|
Content="Mes cercles" />
|
||||||
<!-- Publication toggle: a CheckBox wired to
|
<!-- Publication toggle: a CheckBox wired to
|
||||||
DraftIsPublished. Clicking it fires
|
DraftIsPublished. Clicking it fires
|
||||||
TogglePublishCommand, which pushes the
|
TogglePublishCommand, which pushes the
|
||||||
|
|
|
||||||
|
|
@ -14,56 +14,10 @@ public partial class MainPage : ContentPage
|
||||||
public MainPage()
|
public MainPage()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
DataContextChanged += OnDataContextChanged;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MainPageViewModel? _vm;
|
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>
|
/// <summary>
|
||||||
/// DEV ONLY: temporary shortcut to open the signature capture
|
/// DEV ONLY: temporary shortcut to open the signature capture
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
x:Class="PostIt.Views.PostAclDialog"
|
x:Class="PostIt.Views.PostAclDialog"
|
||||||
xmlns:vm="using:PostIt.ViewModels"
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
xmlns:dtos="using:Yavsc.Api.Client.Dtos"
|
xmlns:dtos="using:Yavsc.Api.Client.Dtos"
|
||||||
|
xmlns:yabst="using:Yavsc.Abstract.Identity.Security"
|
||||||
x:DataType="vm:PostAclDialogViewModel"
|
x:DataType="vm:PostAclDialogViewModel"
|
||||||
>
|
>
|
||||||
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="12">
|
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="12">
|
||||||
|
|
@ -31,13 +32,11 @@
|
||||||
<ListBox Grid.Row="1"
|
<ListBox Grid.Row="1"
|
||||||
ItemsSource="{Binding AclEntries}">
|
ItemsSource="{Binding AclEntries}">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="dtos:CircleAuthorizationDto">
|
<DataTemplate x:DataType="yabst:CircleAuthorization">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<StackPanel Grid.Column="0" Spacing="2">
|
<StackPanel Grid.Column="0" Spacing="2">
|
||||||
<TextBlock Text="{Binding CircleId, StringFormat='Cercle #{0}'}"
|
<TextBlock Text="{Binding CircleId, StringFormat='Cercle #{0}'}"
|
||||||
FontWeight="Bold"/>
|
FontWeight="Bold"/>
|
||||||
<TextBlock Text="{Binding Comment, StringFormat='Commentaires : {0}'}"
|
|
||||||
FontSize="11" Opacity="0.6"/>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Button Grid.Column="1" Content="Révoquer"
|
<Button Grid.Column="1" Content="Révoquer"
|
||||||
Command="{Binding $parent[ContentPage].((vm:PostAclDialogViewModel)DataContext).RevokeCommand}"
|
Command="{Binding $parent[ContentPage].((vm:PostAclDialogViewModel)DataContext).RevokeCommand}"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
using System;
|
|
||||||
using Yavsc.Abstract.Identity.Security;
|
using Yavsc.Abstract.Identity.Security;
|
||||||
|
|
||||||
namespace Yavsc.Blogspot;
|
namespace Yavsc.Blogspot;
|
||||||
|
|
@ -30,18 +29,17 @@ public class BlogPostDto : IBlogPost
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsPublished { get; set; }
|
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()
|
private List<CircleAuthorization> ACL { get; set; } = new List<CircleAuthorization>();
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public string[] GetTags()
|
public string[] Tags { get; set; }
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
public string[] GetTags() => Tags;
|
||||||
}
|
|
||||||
|
public ICircleAuthorization[] GetACL() => ACL.ToArray();
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
namespace Yavsc.Api.Client.Dtos;
|
namespace Yavsc.Abstract.Identity.Security;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wire format for <c>GET /api/blogacl</c> and friends.
|
/// 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
|
/// UI already has the post, and the circles are looked up by id
|
||||||
/// against the list returned by <c>GET /api/circle</c>.</para>
|
/// against the list returned by <c>GET /api/circle</c>.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CircleAuthorizationDto
|
public sealed class CircleAuthorization : ICircleAuthorization
|
||||||
{
|
{
|
||||||
public long CircleId { get; set; }
|
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.Net.Http;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Yavsc.Abstract.Identity.Security;
|
||||||
using Yavsc.Api.Client.Dtos;
|
using Yavsc.Api.Client.Dtos;
|
||||||
|
|
||||||
namespace Yavsc.Api.Client;
|
namespace Yavsc.Api.Client;
|
||||||
|
|
@ -10,7 +11,7 @@ namespace Yavsc.Api.Client;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// HTTP client for <c>/api/blogacl</c> on the Yavsc Blogs server.
|
/// 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
|
/// <c>Circle</c> access to a single <c>BlogPostDto</c>. The server
|
||||||
/// scopes every endpoint to the caller's uid: only the author of
|
/// scopes every endpoint to the caller's uid: only the author of
|
||||||
/// the underlying blog post can list, create, modify, or delete
|
/// the underlying blog post can list, create, modify, or delete
|
||||||
|
|
@ -32,16 +33,16 @@ public sealed class BlogAclApiClient
|
||||||
api.Http.BaseAddress = new Uri(blogsBaseAddress);
|
api.Http.BaseAddress = new Uri(blogsBaseAddress);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<List<CircleAuthorizationDto>> GetMyAclAsync(CancellationToken ct = default)
|
public Task<List<CircleAuthorization>> GetMyAclAsync(CancellationToken ct = default)
|
||||||
=> _api.CallAsync<List<CircleAuthorizationDto>>(HttpMethod.Get, Path, ct: ct);
|
=> _api.CallAsync<List<CircleAuthorization>>(HttpMethod.Get, Path, ct: ct);
|
||||||
|
|
||||||
public Task<CircleAuthorizationDto?> GetAclAsync(long circleId, CancellationToken ct = default)
|
public Task<CircleAuthorization?> GetAclAsync(long circleId, CancellationToken ct = default)
|
||||||
=> _api.CallAsync<CircleAuthorizationDto?>(HttpMethod.Get, $"{Path}/{circleId}", ct: ct);
|
=> _api.CallAsync<CircleAuthorization?>(HttpMethod.Get, $"{Path}/{circleId}", ct: ct);
|
||||||
|
|
||||||
public Task<CircleAuthorizationDto?> GrantAsync(CircleAuthorizationDto acl, CancellationToken ct = default)
|
public Task<CircleAuthorization?> GrantAsync(CircleAuthorization acl, CancellationToken ct = default)
|
||||||
=> _api.CallAsync<CircleAuthorizationDto?>(HttpMethod.Post, Path, body: acl, ct: ct);
|
=> _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);
|
=> _api.CallAsync(HttpMethod.Put, $"{Path}/{circleId}", body: acl, ct: ct);
|
||||||
|
|
||||||
public Task RevokeAsync(long circleId, CancellationToken ct = default)
|
public Task RevokeAsync(long circleId, CancellationToken ct = default)
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ namespace Yavsc.Blogs.Controllers
|
||||||
|
|
||||||
// PUT: api/BlogApi/5
|
// PUT: api/BlogApi/5
|
||||||
[HttpPut("{id}")]
|
[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)
|
if (!ModelState.IsValid)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ namespace Yavsc.Org.Controllers
|
||||||
return View("Title", blogSpotService.GetTitle(id));
|
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);
|
return await blogSpotService.UserPosts(userName, User.GetUserId(), pageLen, pageNum);
|
||||||
|
|
||||||
|
|
@ -95,7 +95,7 @@ namespace Yavsc.Org.Controllers
|
||||||
public IActionResult Create(string title)
|
public IActionResult Create(string title)
|
||||||
{
|
{
|
||||||
var result = new BlogPostEditViewModel
|
var result = new BlogPostEditViewModel
|
||||||
(new BlogPost
|
(new Models.Blog.BlogPost
|
||||||
{
|
{
|
||||||
Title = title
|
Title = title
|
||||||
}, true);
|
}, true);
|
||||||
|
|
@ -105,11 +105,11 @@ namespace Yavsc.Org.Controllers
|
||||||
|
|
||||||
// POST: Blog/Create
|
// POST: Blog/Create
|
||||||
[HttpPost, Authorize, ValidateAntiForgeryToken]
|
[HttpPost, Authorize, ValidateAntiForgeryToken]
|
||||||
public IActionResult Create(BlogPost blogInput)
|
public IActionResult Create(Models.Blog.BlogPost blogInput)
|
||||||
{
|
{
|
||||||
if (ModelState.IsValid)
|
if (ModelState.IsValid)
|
||||||
{
|
{
|
||||||
BlogPost post = blogSpotService.Create(User.GetUserId(),
|
Models.Blog.BlogPost post = blogSpotService.Create(User.GetUserId(),
|
||||||
blogInput, Request.Form.Files);
|
blogInput, Request.Form.Files);
|
||||||
return RedirectToAction("Index");
|
return RedirectToAction("Index");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ public class OldBlogSpotService
|
||||||
this.fileSystemAuthManager = fileSystemAuthManager;
|
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
|
// Sauvegarder le post d'abord pour obtenir son ID
|
||||||
_context.BlogSpot.Add(post);
|
_context.BlogSpot.Add(post);
|
||||||
|
|
@ -102,14 +102,14 @@ public class OldBlogSpotService
|
||||||
return new BlogPostEditViewModel(blog, pub);
|
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.Author)
|
||||||
.Include(p => p.Tags)
|
.Include(p => p.Tags)
|
||||||
.Include(p => p.Comments)
|
.Include(p => p.Comments)
|
||||||
.Include(p => p.ACL)
|
.Include(p => p.ACL)
|
||||||
.SingleAsync(m => m.Id == blogPostId);
|
.SingleAsync((object m) => m.Id == blogPostId);
|
||||||
if (blog == null)
|
if (blog == null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -165,7 +165,7 @@ public class OldBlogSpotService
|
||||||
_context.SaveChanges(user.GetUserId());
|
_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);
|
var existing = await _context.BlogSpot.Include(b => b.ACL).SingleOrDefaultAsync(b => b.Id == blog.Id);
|
||||||
if (existing == null)
|
if (existing == null)
|
||||||
|
|
@ -233,20 +233,20 @@ public class OldBlogSpotService
|
||||||
public async Task Delete(ClaimsPrincipal user, long id)
|
public async Task Delete(ClaimsPrincipal user, long id)
|
||||||
{
|
{
|
||||||
var uid = user.GetUserId();
|
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.BlogSpot.Remove(blog);
|
||||||
_context.SaveChanges(user.GetUserId());
|
_context.SaveChanges(user.GetUserId());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<BlogPost>> UserPosts(
|
public async Task<IEnumerable<Yavsc.Models.Blog.BlogPost>> UserPosts(
|
||||||
string posterName,
|
string posterName,
|
||||||
string? readerId,
|
string? readerId,
|
||||||
int pageLen = 10,
|
int pageLen = 10,
|
||||||
int pageNum = 0)
|
int pageNum = 0)
|
||||||
{
|
{
|
||||||
string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id ?? null;
|
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);
|
return _context.UserPosts(posterId, readerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -259,7 +259,7 @@ public class OldBlogSpotService
|
||||||
).ToList();
|
).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<BlogPost?> GetBlogPostAsync(long value)
|
public async Task<Yavsc.Models.Blog.BlogPost?> GetBlogPostAsync(long value)
|
||||||
{
|
{
|
||||||
return await _context.BlogSpot
|
return await _context.BlogSpot
|
||||||
.Include(b => b.Author)
|
.Include(b => b.Author)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Yavsc.Abstract.Identity;
|
|
||||||
using Yavsc.Abstract.Identity.Security;
|
using Yavsc.Abstract.Identity.Security;
|
||||||
using Yavsc.Models.Access;
|
using Yavsc.Models.Access;
|
||||||
using Yavsc.Models.Relationship;
|
using Yavsc.Models.Relationship;
|
||||||
|
|
@ -69,7 +68,7 @@ namespace Yavsc.Models.Blog
|
||||||
|
|
||||||
public ICircleAuthorization[] GetACL()
|
public ICircleAuthorization[] GetACL()
|
||||||
{
|
{
|
||||||
return ACL.ToArray();
|
return ACL?.ToArray() ?? Array.Empty<ICircleAuthorization>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Tag(Tag tag)
|
public void Tag(Tag tag)
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ public class BlogSpotService
|
||||||
this.fileSystemAuthManager = fileSystemAuthManager;
|
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
|
// Sauvegarder le post d'abord pour obtenir son ID
|
||||||
// Le créateur vient de l'authentification, donc on ne le prend pas du post
|
// 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);
|
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.Author)
|
||||||
.Include(p => p.Tags)
|
.Include(p => p.Tags)
|
||||||
.Include(p => p.Comments)
|
.Include(p => p.Comments)
|
||||||
|
|
@ -170,7 +170,7 @@ public class BlogSpotService
|
||||||
_context.SaveChanges(user.GetUserId());
|
_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);
|
var existing = await _context.BlogSpot.Include(b => b.ACL).SingleOrDefaultAsync(b => b.Id == blog.Id);
|
||||||
if (existing == null)
|
if (existing == null)
|
||||||
|
|
@ -238,7 +238,7 @@ public class BlogSpotService
|
||||||
// the N+1 of one AnyAsync per post. The published ids
|
// the N+1 of one AnyAsync per post. The published ids
|
||||||
// are loaded once and matched against the post list
|
// are loaded once and matched against the post list
|
||||||
// in memory.
|
// in memory.
|
||||||
var postIds = materialised.OfType<BlogPost>().Select(p => p.Id).ToList();
|
var postIds = materialised.Select(p => p.Id).ToList();
|
||||||
if (postIds.Count > 0)
|
if (postIds.Count > 0)
|
||||||
{
|
{
|
||||||
var publishedIds = await _context.blogSpotPublications
|
var publishedIds = await _context.blogSpotPublications
|
||||||
|
|
@ -246,7 +246,7 @@ public class BlogSpotService
|
||||||
.Select(pub => pub.BlogpostId)
|
.Select(pub => pub.BlogpostId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var publishedSet = publishedIds.ToHashSet();
|
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);
|
post.IsPublished = publishedSet.Contains(post.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -259,20 +259,20 @@ public class BlogSpotService
|
||||||
public async Task Delete(ClaimsPrincipal user, long id)
|
public async Task Delete(ClaimsPrincipal user, long id)
|
||||||
{
|
{
|
||||||
var uid = user.GetUserId();
|
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.BlogSpot.Remove(blog);
|
||||||
_context.SaveChanges(user.GetUserId());
|
_context.SaveChanges(user.GetUserId());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<BlogPost>> UserPosts(
|
public async Task<IEnumerable<Yavsc.Models.Blog.BlogPost>> UserPosts(
|
||||||
string posterName,
|
string posterName,
|
||||||
string? readerId,
|
string? readerId,
|
||||||
int pageLen = 10,
|
int pageLen = 10,
|
||||||
int pageNum = 0)
|
int pageNum = 0)
|
||||||
{
|
{
|
||||||
string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id ?? null;
|
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);
|
return _context.UserPosts(posterId, readerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -285,7 +285,7 @@ public class BlogSpotService
|
||||||
).ToList();
|
).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<BlogPost?> GetBlogPostAsync(long value)
|
public async Task<Yavsc.Models.Blog.BlogPost?> GetBlogPostAsync(long value)
|
||||||
{
|
{
|
||||||
return await _context.BlogSpot
|
return await _context.BlogSpot
|
||||||
.Include(b => b.Author)
|
.Include(b => b.Author)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue