From 4ec169ed7179c49dd3f0a9b8de8bd1d6715155d3 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 11 Oct 2026 02:03:48 +0100 Subject: [PATCH] Improve PostIt draft attachment and save workflow Insert attachment links on file addition, resolve provisional links on first save, preserve the editor selection, and highlight unsaved draft changes. Include regression tests and changelog updates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 5 + src/PostIt/PostIt.Tests/BlogApiTestFakes.cs | 8 + .../PostIt.Tests/BlogAttachmentLinkTests.cs | 42 ++++- .../PostIt.Tests/PostItViewModelTests.cs | 85 +++++++++ .../PostIt/ViewModels/Blogs/BlogsViewModel.cs | 177 +++++++++++++----- src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml | 13 +- .../PostIt/Views/Blogs/BlogsPage.axaml.cs | 1 - 7 files changed, 271 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c62779c3..946ee328f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ ### Changed +* [PostIt] Insertion des liens de fichiers dès leur ajout au brouillon ; + les liens provisoires des nouveaux billets sont résolus au premier + enregistrement. Save conserve l'édition ouverte et est mis en valeur + lorsque le titre, le corps ou les pièces jointes sont modifiés. + * [Projet] Instructions Copilot persistantes : mise à jour du changelog par défaut à chaque commit, avec justification explicite des exceptions. diff --git a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs index 112c11752..741b3863c 100644 --- a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs +++ b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs @@ -73,6 +73,14 @@ internal sealed class RecordingYavscApiClient : YavscApiClient public override Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) { _recorder.Calls.Add((method, path, contentFactory)); + if (typeof(T) == typeof(BlogPostDto)) + return Task.FromResult((T)(object)new BlogPostDto + { + Id = 42, + Title = "Mon premier billet", + AuthorId = "tester", + Article = "Contenu du billet de test.", + }); return Task.FromResult(default(T)!); } } diff --git a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs index 6c88e9412..c9d7d3aae 100644 --- a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs +++ b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs @@ -62,18 +62,18 @@ public class BlogAttachmentLinkTests System.Text.Encoding.UTF8.GetBytes("payload test"), "text/plain")); + var expectedUrl = $"{authority}/files/tester/blogs/42/{urlName}"; + Assert.Contains($"- [{originalName}]({expectedUrl})", vm.DraftArticle); + Assert.Empty(recorder.Calls); + var draftBeforeSave = vm.DraftArticle; + await vm.SaveAsync(); - // The update branch issues two PUTs: the multipart one - // carrying the file, then — because TryAppendAttachmentLinks - // appended a link — a JSON one carrying the final article. - var linkPut = recorder.Calls.LastOrDefault( - c => c.method == HttpMethod.Put); + var linkPut = Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put); Assert.NotEqual(default, linkPut); var sent = Assert.IsType>(linkPut.body); var multipartContent = Assert.IsType(sent()); - var expectedUrl = $"{authority}/files/tester/blogs/42/{urlName}"; var sentStringContent = Assert.IsType(multipartContent.First()); var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken); @@ -82,5 +82,35 @@ public class BlogAttachmentLinkTests Assert.Contains($"- [{originalName}]({expectedUrl})", article); // The regression shape: a relative, authority-less link. Assert.DoesNotContain("](/files/", article); + Assert.Equal(draftBeforeSave, vm.DraftArticle); + Assert.NotNull(vm.SelectedPost); + Assert.False(vm.IsDraftModified); + } + + [Fact] + public async Task New_post_resolves_only_the_provisional_link_at_first_save() + { + var recorder = new CallRecorder(); + var api = new RecordingYavscApiClient(recorder); + var vm = new BlogsViewModel(new BlogApiClient(api, "https://blogs.example.test/api/v1/"), + new Settings { Authentication = new AuthenticationSettings { Authority = "https://idp.example.test" } }); + vm.DraftTitle = "Nouveau billet"; + vm.DraftArticle = "Texte conservé."; + vm.DraftAttachments.Add(new BlogUploadFile("Troisi\u00e8me.PDF", new byte[] { 1 })); + + Assert.Contains("postit-attachment:", vm.DraftArticle); + Assert.Empty(recorder.Calls); + await vm.SaveAsync(); + + Assert.Equal("Nouveau billet", vm.DraftTitle); + Assert.Contains("Texte conservé.", vm.DraftArticle); + Assert.Contains("- [Troisi\u00e8me.PDF](https://idp.example.test/files/tester/blogs/42/Troisi_232me.PDF)", + vm.DraftArticle); + Assert.DoesNotContain("postit-attachment:", vm.DraftArticle); + Assert.Equal(42, vm.SelectedPost?.Id); + Assert.Empty(vm.DraftAttachments); + Assert.False(vm.IsDraftModified); + Assert.Single(recorder.Calls, c => c.method == HttpMethod.Post); + Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put); } } diff --git a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs index bf7023997..9a1247657 100644 --- a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs @@ -12,6 +12,91 @@ namespace PostIt.Tests; public class PostItViewModelTests { + [Fact] + public void Draft_modified_tracks_title_body_and_attachments_not_publication() + { + var vm = new BlogsViewModel(new BlogApiClient(new ThrowingYavscApiClient(), "http://localhost/")); + vm.SelectedPost = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" }; + Assert.False(vm.IsDraftModified); + vm.DraftIsPublished = true; + Assert.False(vm.IsDraftModified); + vm.DraftTitle = "Autre"; + Assert.True(vm.IsDraftModified); + vm.DraftTitle = "Titre"; + Assert.False(vm.IsDraftModified); + vm.DraftArticleDocument!.Insert(0, "Edit "); + Assert.True(vm.IsDraftModified); + vm.DraftArticle = "Corps"; + Assert.False(vm.IsDraftModified); + vm.DraftAttachments.Add(new BlogUploadFile("note.txt", new byte[] { 1 })); + Assert.True(vm.IsDraftModified); + } + + [AvaloniaFact] + public async Task Save_keeps_selection_and_editor_open_and_resets_highlight() + { + var recorder = new CallRecorder(); + var vm = new BlogsViewModel(new BlogApiClient(new RecordingYavscApiClient(recorder), + "https://blogs.example.test/api/v1/")); + var selected = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" }; + vm.Posts.Add(selected); + vm.SearchText = ""; + vm.SelectedPost = selected; + var page = new BlogsPage { DataContext = vm }; + var navigation = new NavigationPage(); + await navigation.PushAsync(page); + var window = new Window { Content = navigation }; + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + selected = Assert.Single(vm.FilteredPosts); + selected.AuthorId = "tester"; + selected.Title = "Titre"; + selected.Article = "Corps"; + page.PostsListBox.SelectedItem = selected; + Dispatcher.UIThread.RunJobs(); + Assert.Same(selected, page.PostsListBox.SelectedItem); + Assert.Same(selected, vm.SelectedPost); + Assert.DoesNotContain("modified", page.SaveButton.Classes); + vm.DraftTitle = "Titre modifié"; + Dispatcher.UIThread.RunJobs(); + Assert.Contains("modified", page.SaveButton.Classes); + Assert.Equal(Avalonia.Media.FontWeight.Bold, page.SaveButton.FontWeight); + Assert.Same(vm.SaveCommand, page.SaveButton.Command); + await vm.SaveAsync(); + Dispatcher.UIThread.RunJobs(); + Assert.Same(page, navigation.NavigationStack.Last()); + Assert.Same(selected, vm.SelectedPost); + Assert.Same(selected, page.PostsListBox.SelectedItem); + Assert.Equal("Titre modifié", vm.DraftTitle); + Assert.Equal("Corps", vm.DraftArticle); + Assert.DoesNotContain("modified", page.SaveButton.Classes); + } + finally + { + window.Close(); + } + } + + [Fact] + public async Task Failed_save_preserves_draft_attachments_and_modified_state() + { + var vm = new BlogsViewModel(new BlogApiClient(new ThrowingYavscApiClient(), "http://localhost/")); + var post = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" }; + vm.SelectedPost = post; + vm.DraftTitle = "Titre modifié"; + vm.DraftAttachments.Add(new BlogUploadFile("note.txt", new byte[] { 1 })); + var article = vm.DraftArticle; + await vm.SaveAsync(); + Assert.Same(post, vm.SelectedPost); + Assert.Equal("Titre modifié", vm.DraftTitle); + Assert.Equal(article, vm.DraftArticle); + Assert.Single(vm.DraftAttachments); + Assert.True(vm.IsDraftModified); + Assert.False(vm.IsBusy); + } + [AvaloniaFact] public void Published_badge_tracks_post_publication_state() { diff --git a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs index cecd5cb2c..2e3c34a0f 100644 --- a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs @@ -1,6 +1,7 @@ using System; using System.Collections.ObjectModel; using System.Collections.Generic; +using System.Collections.Specialized; using System.Linq; using System.Threading.Tasks; using Avalonia; @@ -89,12 +90,14 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel } OnPropertyChanged(nameof(DraftArticleDocument)); + UpdateDraftModified(); } } private void DraftArticleDocument_TextChanged(object? sender, EventArgs e) { OnPropertyChanged(nameof(DraftArticle)); + UpdateDraftModified(); } /// Editor buffer for the post's publication state. @@ -108,6 +111,14 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel /// mutable field. Toggling is its own action. [ObservableProperty] public partial bool DraftIsPublished { get; set; } + + [ObservableProperty] + public partial bool IsDraftModified { get; private set; } + + private bool _preserveDraft; + private string _savedTitle = string.Empty; + private string _savedArticle = string.Empty; + private readonly Dictionary _pendingAttachmentLinks = new(); public bool IsLoaded { get; private set; } public Settings SettingsModel { get; } @@ -157,7 +168,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel }); } - [RelayCommand] + [RelayCommand(CanExecute = nameof(CanSave))] internal async Task SaveAsync() { // The button is already disabled when the title is empty @@ -175,16 +186,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel { var attachments = DraftAttachments.ToArray(); - // Build a fresh BlogPostDto from the editor buffer on - // every Save — we no longer mutate SelectedPost in - // place. The previous behavior copied the buffer - // (which was a no-op when SelectedPost was null) - // back onto the model and relied on a - // [Required] violation to surface the missing - // input; the new shape keeps the editor buffer as - // the single source of truth for outgoing payloads - // and the selected post as a read-only hint for - // the update path. if (SelectedPost is null || SelectedPost.Id == 0) { var draft = new BlogPostDto @@ -195,12 +196,22 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel DateModified = DateTime.UtcNow, IsPublished = DraftIsPublished }; + var savedTitle = draft.Title; + var savedArticle = draft.Article; var created = await BlogClient!.CreatePostAsync(draft, attachments); if (created is not null) { - SelectedPost = created; + _preserveDraft = true; + try + { + SelectedPost = created; + } + finally + { + _preserveDraft = false; + } - if (TryAppendAttachmentLinks(created, attachments)) + if (ResolvePendingAttachmentLinks(created)) { var linkUpdate = new BlogPostDto { @@ -212,16 +223,22 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel DateCreated = created.DateCreated, DateModified = DateTime.UtcNow, }; + savedTitle = linkUpdate.Title; + savedArticle = linkUpdate.Article; await BlogClient.UpdatePostAsync(created.Id, linkUpdate); } + created.Title = savedTitle; + created.Article = savedArticle; this.SetInfoStatus($"Billet {created.Id} créé."); DraftAttachments.Clear(); + MarkDraftSaved(savedTitle, savedArticle); } + else + throw new InvalidOperationException("Le serveur n'a pas retourné le billet créé."); } else { - TryAppendAttachmentLinks(SelectedPost, attachments); var update = new BlogPostDto { Id = SelectedPost.Id, @@ -233,8 +250,11 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel DateModified = DateTime.UtcNow, }; await BlogClient!.UpdatePostAsync(SelectedPost.Id, update, attachments); + SelectedPost.Title = update.Title; + SelectedPost.Article = update.Article; this.SetInfoStatus($"Billet {SelectedPost.Id} enregistré."); DraftAttachments.Clear(); + MarkDraftSaved(update.Title, update.Article); } await RefreshPostsAsync(); @@ -430,6 +450,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel Posts = new ObservableCollection(); FilteredPosts = new ObservableCollection(); DraftAttachments = new ObservableCollection(); + DraftAttachments.CollectionChanged += DraftAttachments_CollectionChanged; SelectedPost = null; IsBusy = false; this.SetInfoStatus("Prêt."); @@ -441,6 +462,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel DraftArticle = string.Empty; DraftIsPublished = false; + MarkDraftSaved(); IsLoaded = false; // Production path: DI injects the canonical Settings singleton // and we use it as-is. Test path: tests call this constructor @@ -501,6 +523,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel partial void OnSelectedPostChanged(BlogPostDto? value) { + if (_preserveDraft) return; // Mirror the selection into the editor buffer so the // XAML-bound TextBox/TextEditor show the right content // when the user clicks a post in the list. When the @@ -514,6 +537,8 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel // null selection so a fresh draft starts unpublished. DraftIsPublished = value?.IsPublished ?? false; DraftAttachments.Clear(); + _pendingAttachmentLinks.Clear(); + MarkDraftSaved(); UpdateCommandStates(); } @@ -522,7 +547,28 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel // Save's CanExecute depends on the buffer: the button must // enable as soon as the user has typed a non-whitespace // title, regardless of whether a post is selected. - partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged(); + partial void OnDraftTitleChanged(string value) + { + SaveCommand.NotifyCanExecuteChanged(); + UpdateDraftModified(); + } + + private void UpdateDraftModified() + { + IsDraftModified = !string.Equals(DraftTitle ?? string.Empty, _savedTitle, StringComparison.Ordinal) + || !string.Equals(DraftArticle ?? string.Empty, _savedArticle, StringComparison.Ordinal) + || (DraftAttachments?.Count ?? 0) > 0; + } + + private void MarkDraftSaved() + => MarkDraftSaved(DraftTitle, DraftArticle); + + private void MarkDraftSaved(string? title, string? article) + { + _savedTitle = title ?? string.Empty; + _savedArticle = article ?? string.Empty; + UpdateDraftModified(); + } private async Task RefreshPostsAsync() @@ -535,10 +581,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel } ApplyFilter(); - if (SelectedPost is not null) - { - SelectedPost = Posts.FirstOrDefault(post => post.Id == SelectedPost.Id) ?? SelectedPost; - } } private void ApplyFilter() @@ -553,10 +595,29 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel || p.AuthorId?.Contains(query, StringComparison.OrdinalIgnoreCase) == true) .OrderByDescending(p => p.DateModified); - FilteredPosts.Clear(); - foreach (var post in filtered) + var selection = SelectedPost; + var visiblePosts = filtered.Select(post => post.Id == selection?.Id ? selection : post).ToArray(); + _preserveDraft = true; + try { - FilteredPosts.Add(post); + for (var i = FilteredPosts.Count - 1; i >= 0; i--) + { + if (!visiblePosts.Contains(FilteredPosts[i])) + FilteredPosts.RemoveAt(i); + } + for (var i = 0; i < visiblePosts.Length; i++) + { + var previousIndex = FilteredPosts.IndexOf(visiblePosts[i]); + if (previousIndex < 0) + FilteredPosts.Insert(i, visiblePosts[i]); + else if (previousIndex != i) + FilteredPosts.Move(previousIndex, i); + } + SelectedPost = selection; + } + finally + { + _preserveDraft = false; } } @@ -595,41 +656,53 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel } } - private bool TryAppendAttachmentLinks(BlogPostDto post, IReadOnlyCollection attachments) + private void DraftAttachments_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { - 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) + if (e.NewItems is not null) { - var storedName = AbstractFileSystemHelpers.FilterFileName(attachment.FileName); - var relativePath = $"{EscapePathSegment(ownerSegment)}/blogs/{post.Id}/{EscapePathSegment(storedName)}"; - var fileUrl = ResolveUserFileUrl(relativePath); - var markdownLine = $"- [{attachment.FileName}]({fileUrl})"; - - if (!article.Contains(markdownLine, StringComparison.Ordinal)) - links.Add(markdownLine); + foreach (BlogUploadFile attachment in e.NewItems) + { + var storedName = AbstractFileSystemHelpers.FilterFileName(attachment.FileName); + string fileUrl; + if (SelectedPost is { Id: > 0 } post) + fileUrl = AttachmentUrl(post, storedName); + else + { + fileUrl = $"postit-attachment:{Guid.NewGuid():N}"; + _pendingAttachmentLinks.Add(fileUrl, storedName); + } + var article = DraftArticle ?? string.Empty; + var markdownLine = $"- [{attachment.FileName}]({fileUrl})"; + if (!article.Contains(markdownLine, StringComparison.Ordinal)) + { + var prefix = article.Length == 0 ? "" + : (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n"); + DraftArticle = article + prefix + markdownLine; + } + } } + UpdateDraftModified(); + } - if (links.Count == 0) - return false; + private string AttachmentUrl(BlogPostDto post, string storedName) + { + var owner = post.Author?.UserName ?? post.AuthorId; + if (string.IsNullOrWhiteSpace(owner)) + throw new InvalidOperationException("Le propriétaire du billet est nécessaire au lien du fichier."); + return ResolveUserFileUrl( + $"{EscapePathSegment(owner)}/blogs/{post.Id}/{EscapePathSegment(storedName)}"); + } - var prefix = article.Length == 0 - ? "" - : (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n"); - - DraftArticle = article + prefix + string.Join("\n", links); - return true; + private bool ResolvePendingAttachmentLinks(BlogPostDto post) + { + var article = DraftArticle ?? string.Empty; + foreach (var link in _pendingAttachmentLinks) + article = article.Replace($"({link.Key})", $"({AttachmentUrl(post, link.Value)})", + StringComparison.Ordinal); + var changed = article != DraftArticle; + if (changed) DraftArticle = article; + _pendingAttachmentLinks.Clear(); + return changed; } private string ResolveUserFileUrl(string relativePath) diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml index 7da9a4ce8..4d0858060 100644 --- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml +++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml @@ -17,6 +17,13 @@ + + + @@ -60,6 +69,7 @@ @@ -118,6 +128,7 @@ BorderThickness="1"> diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs index b40021276..5fab966ca 100644 --- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs +++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs @@ -50,7 +50,6 @@ public partial class BlogsPage : ContentPage uploads.Add(new Yavsc.Api.Client.BlogUploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name))); } - vm.DraftAttachments.Clear(); foreach (var upload in uploads) vm.DraftAttachments.Add(upload); }