Improve PostIt draft attachment and save workflow
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
Forgejo Release / release (push) Successful in 26m22s

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>
This commit is contained in:
Paul Schneider 2026-10-11 02:03:48 +01:00
commit 4ec169ed71
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
7 changed files with 271 additions and 60 deletions

View file

@ -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.

View file

@ -73,6 +73,14 @@ internal sealed class RecordingYavscApiClient : YavscApiClient
public override Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> 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)!);
}
}

View file

@ -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<System.Func<HttpContent>>(linkPut.body);
var multipartContent = Assert.IsType<MultipartFormDataContent>(sent());
var expectedUrl = $"{authority}/files/tester/blogs/42/{urlName}";
var sentStringContent = Assert.IsType<StringContent>(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);
}
}

View file

@ -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()
{

View file

@ -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();
}
/// <summary>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.</summary>
[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<string, string> _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<BlogPostDto>();
FilteredPosts = new ObservableCollection<BlogPostDto>();
DraftAttachments = new ObservableCollection<BlogUploadFile>();
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<BlogUploadFile> 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<string>();
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)

View file

@ -17,6 +17,13 @@
<Design.DataContext>
<vm:BlogsViewModel />
</Design.DataContext>
<ContentPage.Styles>
<Style Selector="CommandBarButton.modified">
<Setter Property="Background" Value="#FFF0C2" />
<Setter Property="Foreground" Value="#663C00" />
<Setter Property="FontWeight" Value="Bold" />
</Style>
</ContentPage.Styles>
<Grid
RowDefinitions="Auto,Auto,*"
Margin="5,5,10,5"
@ -46,7 +53,9 @@
</CommandBarButton>
<CommandBarButton
x:Name="SaveButton"
Command="{Binding SaveAsync}"
Command="{Binding SaveCommand}"
Classes.modified="{Binding IsDraftModified}"
IsEnabled="{Binding !IsBusy}"
Label="Save">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource SaveIcon}" />
@ -60,6 +69,7 @@
<CommandBarButton
x:Name="AddAttachmentButton"
Click="AddAttachment_Click"
IsEnabled="{Binding !IsBusy}"
Label="Ajouter fichiers">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource AddIcon}" />
@ -118,6 +128,7 @@
BorderThickness="1">
<ListBox
x:Name="PostsListBox"
IsEnabled="{Binding !IsBusy}"
ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="models:BlogPostDto">

View file

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