diff --git a/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs b/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs index 04cc0f314..6cac39603 100644 --- a/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs @@ -162,6 +162,12 @@ public class ActivitiesPageViewModelTests return Task.CompletedTask; } + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } diff --git a/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs b/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs index 17fcc968c..34804b4cd 100644 --- a/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs @@ -352,6 +352,12 @@ public class BillingCommandPageViewModelTests return Task.CompletedTask; } + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } diff --git a/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs b/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs index a7f2b0844..0740963b6 100644 --- a/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs @@ -129,6 +129,12 @@ public class BillingQueriesPageViewModelTests return Task.CompletedTask; } + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } diff --git a/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs b/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs index d1d00532d..8abf89139 100644 --- a/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs +++ b/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs @@ -8,6 +8,7 @@ using Yavsc.Blogspot; using PostIt.Services; using PostIt.ViewModels; using PostIt.Views; +using PostIt.Views.Blogs; namespace PostIt.Tests; @@ -72,13 +73,13 @@ public class MainPageButtonsTests { } } - private static MainViewModel MakeViewModel(BlogPostDto? selectedPost = null) + private static BlogsViewModel 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 + // Minimal DI graph: only what BlogsViewModel resolves // when the user clicks a navigation button. Today that's // SignaturePageViewModel / CirclesPageViewModel / ACL // dependencies. The graph intentionally stays local to this @@ -93,7 +94,7 @@ public class MainPageButtonsTests services.AddTransient(); services.AddTransient(); services.AddTransient(); - var vm = new MainViewModel(blog, services: services.BuildServiceProvider()); + var vm = new BlogsViewModel(blog, services: services.BuildServiceProvider()); if (selectedPost is not null) vm.SelectedPost = selectedPost; return vm; } @@ -101,7 +102,7 @@ public class MainPageButtonsTests /// /// Mount a real (as /// SessionStatusBannerTests does), push a - /// with the given VM onto + /// with the given VM onto /// NavRoot. PushAsync is awaited (via /// GetAwaiter().GetResult()) so the page is on the /// nav stack before the test tries to interact with its @@ -109,10 +110,10 @@ public class MainPageButtonsTests /// realised and KeyPressQwerty has a real /// to dispatch against. /// - private static (MainView window, MainPage page) MountMainPage(MainViewModel vm) + private static (MainView window, BlogsPage page) MountMainPage(BlogsViewModel vm) { var window = new MainView(); - var page = new MainPage { DataContext = vm }; + var page = new BlogsPage { DataContext = vm }; var app = (PostIt.App)Application.Current!; app.AttachMainWindow(window); window.NavRoot.PushAsync(page).GetAwaiter().GetResult(); @@ -203,7 +204,7 @@ public class MainPageButtonsTests public void Signature_dev_button_click_pushes_a_page_onto_nav_stack() { // Arrange: the "[DEV] Signature" button is bound to the - // MainPageViewModel.OpenSignatureDevCommand [RelayCommand]. + // BlogsViewModel.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 diff --git a/src/PostIt/PostIt.Tests/MainPageSaveTests.cs b/src/PostIt/PostIt.Tests/MainPageSaveTests.cs index 0fd5627c8..cac2f5aaf 100644 --- a/src/PostIt/PostIt.Tests/MainPageSaveTests.cs +++ b/src/PostIt/PostIt.Tests/MainPageSaveTests.cs @@ -5,10 +5,11 @@ using Yavsc.Blogspot; using Yavsc.Api.Client; using PostIt.ViewModels; using PostIt.Views; +using PostIt.Views.Blogs; namespace PostIt.Tests; /// -/// Headless UI tests for the "Save" flow in . +/// Headless UI tests for the "Save" flow in . /// The pattern is the one SessionStatusBannerTests /// established: [AvaloniaFact], a /// hosting the page (via a because @@ -40,9 +41,9 @@ public class MainPageSaveTests var recorder = new CallRecorder(); var api = new RecordingYavscApiClient(recorder); var blog = new BlogApiClient(api, "http://localhost/"); - var viewModel = new MainViewModel(blog); + var viewModel = new BlogsViewModel(blog); - var page = new MainPage { DataContext = viewModel }; + var page = new BlogsPage { DataContext = viewModel }; // MainPage is a ContentPage (a Page, not a Control), so it // must be hosted in a navigation surface. The production // MainWindow.axaml uses NavigationPage, and the API is the diff --git a/src/PostIt/PostIt.Tests/PostAclDialogTests.cs b/src/PostIt/PostIt.Tests/PostAclDialogTests.cs index e7175203c..ed8282173 100644 --- a/src/PostIt/PostIt.Tests/PostAclDialogTests.cs +++ b/src/PostIt/PostIt.Tests/PostAclDialogTests.cs @@ -270,6 +270,12 @@ public class PostAclDialogTests return Task.CompletedTask; } + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } diff --git a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs index 1a867bd61..821a778dd 100644 --- a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs @@ -11,12 +11,12 @@ public class PostItViewModelTests [Fact] public void SearchCommand_filters_posts_by_title_article_or_author() { - // MainPageViewModel no longer owns a BlogApiClient instance by + // BlogsViewModel no longer owns a BlogApiClient instance by // default; tests construct one with a fake YavscApiClient that // throws on any call (we never call the API in this test). var fakeApi = new ThrowingYavscApiClient(); var blog = new BlogApiClient(fakeApi, "http://localhost/"); - var viewModel = new MainViewModel(blog); + var viewModel = new BlogsViewModel(blog); viewModel.Posts.Add(new BlogPostDto { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" }); viewModel.Posts.Add(new BlogPostDto { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" }); @@ -60,7 +60,7 @@ public class PostItViewModelTests { var api = new RecordingPublishApi(); var blog = new BlogApiClient(api, "http://localhost/"); - var viewModel = new MainViewModel(blog); + var viewModel = new BlogsViewModel(blog); viewModel.SelectedPost = new BlogPostDto { Id = 42, IsPublished = false }; @@ -146,6 +146,12 @@ public class PostItViewModelTests return Task.CompletedTask; } + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } diff --git a/src/PostIt/PostIt.Tests/RdvPageHeadlessTests.cs b/src/PostIt/PostIt.Tests/RdvPageHeadlessTests.cs index fdf8689ab..1bcb8a0d8 100644 --- a/src/PostIt/PostIt.Tests/RdvPageHeadlessTests.cs +++ b/src/PostIt/PostIt.Tests/RdvPageHeadlessTests.cs @@ -74,6 +74,12 @@ public class RdvPageHeadlessTests public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) => Task.CompletedTask; + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } -} \ No newline at end of file +} diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index 4d748f87d..7e3187d8b 100644 --- a/src/PostIt/PostIt/Services/YavscApiClient.cs +++ b/src/PostIt/PostIt/Services/YavscApiClient.cs @@ -191,7 +191,28 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable object? body = null, CancellationToken ct = default) { - using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false); + using var response = await SendAsync(method, path, body is null ? null : () => JsonContent.Create(body), ct).ConfigureAwait(false); + await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false); + + var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + var dto = await JsonSerializer.DeserializeAsync(stream, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, ct).ConfigureAwait(false); + return dto!; + } + + /// + /// Call a multipart endpoint, transparently refreshing the token if needed. + /// + public virtual async Task CallAsync( + HttpMethod method, + string path, + Func contentFactory, + CancellationToken ct = default) + { + if (contentFactory is null) + throw new ArgumentNullException(nameof(contentFactory)); + + using var response = await SendAsync(method, path, contentFactory, ct).ConfigureAwait(false); await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false); var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); @@ -217,7 +238,23 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable object? body = null, CancellationToken ct = default) { - using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false); + using var response = await SendAsync(method, path, body is null ? null : () => JsonContent.Create(body), ct).ConfigureAwait(false); + await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false); + } + + /// + /// Call a multipart endpoint that returns no useful body (DELETE, etc.). + /// + public async Task CallAsync( + HttpMethod method, + string path, + Func contentFactory, + CancellationToken ct = default) + { + if (contentFactory is null) + throw new ArgumentNullException(nameof(contentFactory)); + + using var response = await SendAsync(method, path, contentFactory, ct).ConfigureAwait(false); await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false); } @@ -232,7 +269,7 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable => CallAsync(method, path, body: null, ct); private async Task SendAsync( - HttpMethod method, string path, object? body, CancellationToken ct) + HttpMethod method, string path, Func? contentFactory, CancellationToken ct) { if (_tokens is null) throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first."); @@ -240,8 +277,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable await EnsureFreshTokenAsync(ct).ConfigureAwait(false); using var req = new HttpRequestMessage(method, path); - if (body is not null) - req.Content = JsonContent.Create(body); + if (contentFactory is not null) + req.Content = contentFactory(); var response = await Http.SendAsync(req, ct).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.Unauthorized) @@ -252,8 +289,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable await ForceRefreshAsync(ct).ConfigureAwait(false); using var retry = new HttpRequestMessage(method, path); - if (body is not null) - retry.Content = JsonContent.Create(body); + if (contentFactory is not null) + retry.Content = contentFactory(); response = await Http.SendAsync(retry, ct).ConfigureAwait(false); } diff --git a/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs index bccf97167..aab923d80 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs @@ -43,7 +43,7 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV ? $"Demandes en cours ({Form.Title})" : $"Commandes {Form.Title}"; public string ContextLabel => $"{Performer.UserName} · {Activity.Name}"; - public bool CanOpenDetails => true; + public bool CanOpenDetails => !IsReadOnly; public override bool CanNavigateNext { @@ -75,7 +75,7 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV public Task InitializeAsync() => RefreshAsync(); - private bool CanOpenSelectedQuery() => SelectedQuery is not null; + private bool CanOpenSelectedQuery() => CanOpenDetails && SelectedQuery is not null; [RelayCommand] public async Task RefreshAsync() diff --git a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs index 94a143aa4..8ac05c85e 100644 --- a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.ObjectModel; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Avalonia; @@ -8,6 +9,7 @@ using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.DependencyInjection; using Yavsc.Blogspot; using Yavsc.Api.Client; +using Yavsc.Abstract.Files; using PostIt.Helpers; namespace PostIt.ViewModels; @@ -66,6 +68,9 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel [ObservableProperty] public partial ObservableCollection FilteredPosts { get; set; } + [ObservableProperty] + public partial ObservableCollection DraftAttachments { get; set; } + [ObservableProperty] public partial BlogPostDto? SelectedPost { get; set; } @@ -113,6 +118,8 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel await ExecuteAsync(async () => { + var attachments = DraftAttachments.ToArray(); + // Build a fresh BlogPostDto from the editor buffer on // every Save — we no longer mutate SelectedPost in // place. The previous behaviour copied the buffer @@ -133,11 +140,28 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel DateModified = DateTime.UtcNow, IsPublished = DraftIsPublished }; - var created = await BlogClient!.CreatePostAsync(draft); + var created = await BlogClient!.CreatePostAsync(draft, attachments); if (created is not null) { SelectedPost = created; + + if (TryAppendAttachmentLinks(created, attachments)) + { + var linkUpdate = new BlogPostDto + { + Id = created.Id, + AuthorId = created.AuthorId, + Photo = created.Photo, + Title = DraftTitle, + Article = DraftArticle ?? string.Empty, + DateCreated = created.DateCreated, + DateModified = DateTime.UtcNow, + }; + await BlogClient.UpdatePostAsync(created.Id, linkUpdate); + } + this.SetInfoStatus($"Billet {created.Id} créé."); + DraftAttachments.Clear(); } } else @@ -152,8 +176,26 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel DateCreated = SelectedPost.DateCreated, DateModified = DateTime.UtcNow, }; - await BlogClient!.UpdatePostAsync(SelectedPost.Id, update); + + await BlogClient!.UpdatePostAsync(SelectedPost.Id, update, attachments); + + if (TryAppendAttachmentLinks(SelectedPost, attachments)) + { + var linkUpdate = new BlogPostDto + { + Id = SelectedPost.Id, + AuthorId = SelectedPost.AuthorId, + Photo = SelectedPost.Photo, + Title = DraftTitle, + Article = DraftArticle ?? string.Empty, + DateCreated = SelectedPost.DateCreated, + DateModified = DateTime.UtcNow, + }; + await BlogClient.UpdatePostAsync(SelectedPost.Id, linkUpdate); + } + this.SetInfoStatus($"Billet {SelectedPost.Id} enregistré."); + DraftAttachments.Clear(); } await RefreshPostsAsync(); @@ -347,6 +389,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel { Posts = new ObservableCollection(); FilteredPosts = new ObservableCollection(); + DraftAttachments = new ObservableCollection(); SelectedPost = null; IsBusy = false; this.SetInfoStatus("Prêt."); @@ -427,6 +470,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel // Mirror publication state too. Defaults to false on // null selection so a fresh draft starts unpublished. DraftIsPublished = value?.IsPublished ?? false; + DraftAttachments.Clear(); UpdateCommandStates(); } @@ -508,4 +552,55 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel IsLoaded = true; } } + + private bool TryAppendAttachmentLinks(BlogPostDto post, IReadOnlyCollection attachments) + { + if (attachments.Count == 0) + return false; + + var ownerSegment = post.Author?.UserName; + if (string.IsNullOrWhiteSpace(ownerSegment)) + ownerSegment = post.AuthorId; + + if (string.IsNullOrWhiteSpace(ownerSegment)) + return false; + + var article = DraftArticle ?? string.Empty; + var links = new List(); + + foreach (var attachment in attachments) + { + var relativePath = $"{EscapePathSegment(ownerSegment)}/blogs/{post.Id}/{EscapePathSegment(attachment.FileName)}"; + var fileUrl = ResolveUserFileUrl(relativePath); + var markdownLine = $"- [{attachment.FileName}]({fileUrl})"; + + if (!article.Contains(markdownLine, StringComparison.Ordinal)) + links.Add(markdownLine); + } + + if (links.Count == 0) + return false; + + var prefix = article.Length == 0 + ? "" + : (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n"); + + DraftArticle = article + prefix + string.Join("\n", links); + return true; + } + + private string ResolveUserFileUrl(string relativePath) + { + var authority = Settings?.Authentication?.Authority; + if (!string.IsNullOrWhiteSpace(authority) + && Uri.TryCreate(authority, UriKind.Absolute, out var baseUri)) + { + return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).ToString(); + } + + return $"{Yavsc.Constants.UserFilesPath}/{relativePath}"; + } + + private static string EscapePathSegment(string segment) + => Uri.EscapeDataString(segment); } diff --git a/src/PostIt/PostIt/Views/Blogs/MainPage.axaml b/src/PostIt/PostIt/Views/Blogs/MainPage.axaml index c93aef2c2..2ded71b94 100644 --- a/src/PostIt/PostIt/Views/Blogs/MainPage.axaml +++ b/src/PostIt/PostIt/Views/Blogs/MainPage.axaml @@ -34,6 +34,9 @@