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); }