diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3faedae0..fd46437a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -7,12 +7,13 @@ "fileLocation": ["relative", "${workspaceFolder}"], "source": "dotnet", "pattern": { - "regexp": "^\\s+(.*)\\((\\d+):(\\d+)\\):\\s+(error|warning)\\s+(.*)$", + "regexp": "^\\s+(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.+): (.*)$", "file": 1, "line": 2, "column": 3, "severity": 4, - "message": 5 + "code": 5, + "message": 6 } } ], diff --git a/CHANGELOG.md b/CHANGELOG.md index 7210a227..a33ba729 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,16 @@ # Changelog -Toutes les modifications notables de PostIt et de la plateforme Yavsc -sont documentées dans ce fichier. +## [1.0.8-rc7] - unstable -Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), -et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +### Added -À noter : la **parité du numéro de patch** porte une signification de canal : +* [PostIt] The search pattern now persists -- **patch pair** (ex. `1.0.0`, `1.0.2`) → **stable** -- **patch impair** (ex. `1.0.1`, `1.0.3`) → **preview** -- **suffixe** (ex. `1.0.0-rc1`, `1.0.0-alpha`) → **instable** +### Changed -Cette convention est partagée avec le dépôt -[`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian) -pour la production des paquets `.deb`. +* The blog spot path is now `/api/v1/blogspot` (yet in last release) + +### Fixed ## [1.0.8-rc6] - unstable diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b9969ea5..fd408c5c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,6 +49,26 @@ Les tests sont répartis en : item « Tests d'intégration smoke par BC ». - `src/PostIt.Tests/` — tests unitaires du client desktop PostIt. +## Le CHANGELOG.md + +Le `CHANGELOG.md` est un document de changement de version + +Toutes les modifications notables de PostIt et de la plateforme Yavsc +sont documentées dans ce fichier. + +Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), +et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +À noter : la **parité du numéro de patch** porte une signification de canal : + +- **patch pair** (ex. `1.0.0`, `1.0.2`) → **stable** +- **patch impair** (ex. `1.0.1`, `1.0.3`) → **preview** +- **suffixe** (ex. `1.0.0-rc1`, `1.0.0-alpha`) → **instable** + +Cette convention est partagée avec le dépôt +[`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian) +pour la production des paquets `.deb`. + ## Navigation (PostIt) La navigation est centralisée dans diff --git a/src/PostIt/PostIt.Tests/MainPageSaveTests.cs b/src/PostIt/PostIt.Tests/MainPageSaveTests.cs index 5baae658..cef67f0f 100644 --- a/src/PostIt/PostIt.Tests/MainPageSaveTests.cs +++ b/src/PostIt/PostIt.Tests/MainPageSaveTests.cs @@ -81,7 +81,7 @@ public class MainPageSaveTests Assert.NotEmpty(recorder.Calls); var (method, path, body) = recorder.FirstCall; Assert.Equal(HttpMethod.Post, method); - Assert.Equal("blog", path); + Assert.Equal("blogspot", path); var sent = Assert.IsType(body); Assert.Equal(typed, sent.Title); } diff --git a/src/PostIt/PostIt.Tests/SettingsLoadTests.cs b/src/PostIt/PostIt.Tests/SettingsLoadTests.cs index 43021caa..3f350240 100644 --- a/src/PostIt/PostIt.Tests/SettingsLoadTests.cs +++ b/src/PostIt/PostIt.Tests/SettingsLoadTests.cs @@ -1,3 +1,5 @@ +using System.Text.Json; + namespace PostIt.Tests; public class SettingsLoadTests @@ -149,4 +151,26 @@ public class SettingsLoadTests Assert.True(settings.Loaded); } + + [Fact] + public void SearchText_is_serialized_in_settings_and_round_trips() + { + var settings = new PostIt.ViewModels.Settings + { + Authentication = new AuthenticationSettings + { + Authority = "https://example.test/", + ClientId = "postit-tests", + Scopes = new[] { "openid" } + } + }; + + settings.SearchText = "bonjour"; + + var json = JsonSerializer.Serialize(settings); + var roundTrip = JsonSerializer.Deserialize(json); + + Assert.NotNull(roundTrip); + Assert.Equal("bonjour", roundTrip.SearchText); + } } diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index e3735c1e..f43aa1d9 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -157,6 +157,7 @@ private void ConfigureRootView(MainView rootView) { var app = (App)Current!; var mainVm = app.ServiceProvider!.GetRequiredService(); + await mainVm.InitializeAsync(); await app.PushPageAsync(mainVm); } diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index c4e774cc..5f19292a 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -42,17 +42,9 @@ public static class ServiceCollectionHelpers // the navigation stack, each bound to a fresh // SettingsViewModel and missing any in-flight edits. services.AddSingleton(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - // Dialogs (modal-light pages): the ViewLocator resolves - // them when a caller pushes a PostAclDialogViewModel or - // AddCircleMemberDialogViewModel via App.PushPageAsync. - // App.PushPageAsync overwrites the page's DataContext with - // the caller-built VM, so the parameterless ctor is enough - // here — the parametrised ctors stay for direct test wiring. - services.AddTransient(); - services.AddTransient(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // ViewModels services.AddSingleton(settings); services.AddSingleton(api); @@ -61,18 +53,25 @@ public static class ServiceCollectionHelpers services.AddSingleton(blogAclClient); services.AddSingleton(userSearchClient); services.AddSingleton(userDirectory); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + // Dialogs (modal-light pages): the ViewLocator resolves + // them when a caller pushes a PostAclDialogViewModel or + // AddCircleMemberDialogViewModel via App.PushPageAsync. + // App.PushPageAsync overwrites the page's DataContext with + // the caller-built VM, so the parameterless ctor is enough + // here — the parametrised ctors stay for direct test wiring. + services.AddTransient(); + services.AddTransient(); // 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(); - + services.AddSingleton(); + services.AddSingleton(); return services.BuildServiceProvider(); } } diff --git a/src/PostIt/PostIt/ViewModels/MainViewModel.cs b/src/PostIt/PostIt/ViewModels/MainViewModel.cs index 1aa3eee0..2b065cae 100644 --- a/src/PostIt/PostIt/ViewModels/MainViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainViewModel.cs @@ -48,7 +48,7 @@ public partial class MainViewModel : ViewModelBase /// mutable field. Toggling is its own action. [ObservableProperty] public partial bool DraftIsPublished { get; set; } - + public bool IsLoaded { get; private set; } public Settings SettingsModel { get; } [ObservableProperty] @@ -72,6 +72,194 @@ public partial class MainViewModel : ViewModelBase [ObservableProperty] public partial Settings Settings { get; private set; } + [RelayCommand] + internal async Task RefreshAsync() + { + await ExecuteAsync(async () => + { + var posts = await BlogClient!.GetPostsAsync(); + Posts.Clear(); + foreach (var post in posts.OrderByDescending(p => p.DateModified)) + { + Posts.Add(post); + } + ApplyFilter(); + StatusMessage = $"Loaded {Posts.Count} posts."; + }); + } + + [RelayCommand] + internal async Task SearchAsync() { + await RefreshAsync(); + ApplyFilter(); + } + + [RelayCommand] + internal async Task SaveAsync() + { + // The button is already disabled when the title is empty + // (see CanSave), but the test path (and any programmatic + // ICommand.Execute) bypasses CanExecute, so we still + // guard here. Better to no-op with a status message + // than to send a request the server will reject. + if (string.IsNullOrWhiteSpace(DraftTitle)) + { + StatusMessage = "Title is required."; + return; + } + + await ExecuteAsync(async () => + { + // Build a fresh BlogPostDto from the editor buffer on + // every Save — we no longer mutate SelectedPost in + // place. The previous behaviour 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 + { + Title = DraftTitle, + Article = DraftArticle ?? string.Empty, + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow, + IsPublished = DraftIsPublished + }; + var created = await BlogClient!.CreatePostAsync(draft); + if (created is not null) + { + SelectedPost = created; + StatusMessage = $"Created post {created.Id}."; + } + } + else + { + var update = 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, update); + StatusMessage = $"Saved post {SelectedPost.Id}."; + } + + await RefreshPostsAsync(); + }); + } + + [RelayCommand] + internal async Task DeleteAsync() + { + if (SelectedPost is null || SelectedPost.Id == 0) + { + StatusMessage = "Select an existing post before deleting."; + return; + } + + await ExecuteAsync(async () => + { + await BlogClient!.DeletePostAsync(SelectedPost.Id); + StatusMessage = $"Deleted post {SelectedPost.Id}."; + SelectedPost = null; + await RefreshPostsAsync(); + }); + } + + /// + /// Toggle the publication state of the currently selected + /// post. Pushes the new state to + /// PUT /api/BlogApi/{id}/publish and reflects it + /// locally in + the + /// selected post so the UI updates without a full + /// refresh. + /// + /// The toggle is its own action — separate from Save + /// — because Publish is not part of the + /// BlogPostDto payload. Bundling it into Save + /// would require a wire-shape change and a second server + /// overload; the dedicated endpoint keeps the wire + /// contract clean. + /// + [RelayCommand] + internal async Task TogglePublishAsync() + { + if (SelectedPost is null || SelectedPost.Id == 0) + { + StatusMessage = "Sélectionnez un billet existant pour changer sa publication."; + return; + } + + await ExecuteAsync(async () => + { + var desired = !DraftIsPublished; + await BlogClient!.SetPublishAsync(SelectedPost.Id, desired); + DraftIsPublished = desired; + // Mirror into the selected post so a subsequent + // RefreshPostsAsync() doesn't blow away the + // locally flipped state until the round-trip + // re-hydrates it. + SelectedPost.IsPublished = desired; + StatusMessage = desired + ? $"Billet {SelectedPost.Id} publié." + : $"Billet {SelectedPost.Id} remis en brouillon."; + }); + } + + /// + /// DEV ONLY: open the signature capture page. The production + /// entry point is a SignalR push from Yavsc.Org ("devis + /// received, sign here"); this command is the dev-time + /// shortcut to reach the page without that infrastructure. + /// Aligned on the same VM-first navigation pattern as + /// : the VM resolves the target VM + /// through , the ViewLocator picks + /// the matching Control at bind time. No + /// Click handler, no App.ServiceProvider + /// access from the view layer. + /// + [RelayCommand] + internal async Task OpenSignatureDevAsync() + { + await ((App)App.Current!).PushPageAsync(SignatureModel).ConfigureAwait(true); + } + + + [RelayCommand(CanExecute = nameof(CanManageAcl))] + public async Task ManageAclAsync() + { + if (SelectedPost is null) + { + StatusMessage = "Select an existing post before managing ACL."; + return; + } + await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true); + } + + [RelayCommand] + public async Task OpenCirclesAsync() + { + var circlesVm = ResolveServices().GetRequiredService(); + await ((App)App.Current!).PushPageAsync(circlesVm).ConfigureAwait(true); + } + + private ViewModelBase GetACLViewModel(BlogPostDto selectedPost) + { + var sp = ResolveServices(); + var aclClient = sp.GetRequiredService(); + var circleClient = sp.GetRequiredService(); + return new PostAclDialogViewModel(selectedPost, aclClient, circleClient); + } + /// /// API surface that hits the Yavsc.Blogs deployment at /// . Owned and constructed by @@ -130,17 +318,18 @@ public partial class MainViewModel : ViewModelBase private void Init(Settings? settings) { - SearchText = string.Empty; Posts = new ObservableCollection(); FilteredPosts = new ObservableCollection(); SelectedPost = null; IsBusy = false; StatusMessage = "Ready"; Settings = settings ?? new Settings(); + SearchText = Settings.SearchText; WindowTitle = "PostIt"; DraftTitle = string.Empty; DraftArticle = string.Empty; DraftIsPublished = false; + IsLoaded = false; // Production path: DI injects the canonical Settings singleton // and we use it as-is. Test path: tests call this constructor // without a Settings argument; we fall back to a fresh @@ -152,6 +341,15 @@ public partial class MainViewModel : ViewModelBase // (thread-safe dispatcher marshalling) so the duplicate // instance is now merely wasteful, not dangerous. + Settings.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(Settings.SearchText)) + { + SearchText = Settings.SearchText; + ApplyFilter(); + } + }; + } /// Save is enabled as soon as the user has typed @@ -179,7 +377,14 @@ public partial class MainViewModel : ViewModelBase Init(settings); } - partial void OnSearchTextChanged(string value) => ApplyFilter(); + partial void OnSearchTextChanged(string value) + { + if (Settings is not null && Settings.SearchText != value) + { + Settings.SearchText = value; + } + ApplyFilter(); + } partial void OnSelectedPostChanged(BlogPostDto? value) { @@ -206,170 +411,6 @@ public partial class MainViewModel : ViewModelBase partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged(); partial void OnDraftArticleChanged(string value) => SaveCommand.NotifyCanExecuteChanged(); - [RelayCommand] - internal async Task LoadPosts() - { - await ExecuteAsync(async () => - { - var posts = await BlogClient!.GetPostsAsync(); - Posts.Clear(); - foreach (var post in posts.OrderByDescending(p => p.DateModified)) - { - Posts.Add(post); - } - ApplyFilter(); - StatusMessage = $"Loaded {Posts.Count} posts."; - }); - } - - [RelayCommand] - internal void Search() => ApplyFilter(); - - [RelayCommand] - internal async Task Save() - { - // The button is already disabled when the title is empty - // (see CanSave), but the test path (and any programmatic - // ICommand.Execute) bypasses CanExecute, so we still - // guard here. Better to no-op with a status message - // than to send a request the server will reject. - if (string.IsNullOrWhiteSpace(DraftTitle)) - { - StatusMessage = "Title is required."; - return; - } - - await ExecuteAsync(async () => - { - // Build a fresh BlogPostDto from the editor buffer on - // every Save — we no longer mutate SelectedPost in - // place. The previous behaviour 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 - { - Title = DraftTitle, - Article = DraftArticle ?? string.Empty, - DateCreated = DateTime.UtcNow, - DateModified = DateTime.UtcNow, - }; - var created = await BlogClient!.CreatePostAsync(draft); - if (created is not null) - { - SelectedPost = created; - StatusMessage = $"Created post {created.Id}."; - } - } - else - { - var update = 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, update); - StatusMessage = $"Saved post {SelectedPost.Id}."; - } - - await RefreshPostsAsync(); - }); - } - - [RelayCommand] - internal async Task Delete() - { - if (SelectedPost is null || SelectedPost.Id == 0) - { - StatusMessage = "Select an existing post before deleting."; - return; - } - - await ExecuteAsync(async () => - { - await BlogClient!.DeletePostAsync(SelectedPost.Id); - StatusMessage = $"Deleted post {SelectedPost.Id}."; - SelectedPost = null; - await RefreshPostsAsync(); - }); - } - - /// - /// Toggle the publication state of the currently selected - /// post. Pushes the new state to - /// PUT /api/BlogApi/{id}/publish and reflects it - /// locally in + the - /// selected post so the UI updates without a full - /// refresh. - /// - /// The toggle is its own action — separate from Save - /// — because Publish is not part of the - /// BlogPostDto payload. Bundling it into Save - /// would require a wire-shape change and a second server - /// overload; the dedicated endpoint keeps the wire - /// contract clean. - /// - [RelayCommand] - internal async Task TogglePublish() - { - if (SelectedPost is null || SelectedPost.Id == 0) - { - StatusMessage = "Sélectionnez un billet existant pour changer sa publication."; - return; - } - - await ExecuteAsync(async () => - { - var desired = !DraftIsPublished; - await BlogClient!.SetPublishAsync(SelectedPost.Id, desired); - DraftIsPublished = desired; - // Mirror into the selected post so a subsequent - // RefreshPostsAsync() doesn't blow away the - // locally flipped state until the round-trip - // re-hydrates it. - SelectedPost.IsPublished = desired; - StatusMessage = desired - ? $"Billet {SelectedPost.Id} publié." - : $"Billet {SelectedPost.Id} remis en brouillon."; - }); - } - - /// - /// DEV ONLY: open the signature capture page. The production - /// entry point is a SignalR push from Yavsc.Org ("devis - /// received, sign here"); this command is the dev-time - /// shortcut to reach the page without that infrastructure. - /// Aligned on the same VM-first navigation pattern as - /// : the VM resolves the target VM - /// through , the ViewLocator picks - /// the matching Control at bind time. No - /// Click handler, no App.ServiceProvider - /// access from the view layer. - /// - [RelayCommand] - internal async Task OpenSignatureDev() - { - await ((App)App.Current!).PushPageAsync(SignatureModel).ConfigureAwait(true); - } - - private ViewModelBase GetACLViewModel(BlogPostDto selectedPost) - { - var sp = ResolveServices(); - var aclClient = sp.GetRequiredService(); - var circleClient = sp.GetRequiredService(); - return new PostAclDialogViewModel(selectedPost, aclClient, circleClient); - } private async Task RefreshPostsAsync() { @@ -426,28 +467,18 @@ public partial class MainViewModel : ViewModelBase private void UpdateCommandStates() { - LoadPostsCommand.NotifyCanExecuteChanged(); + RefreshCommand.NotifyCanExecuteChanged(); SaveCommand.NotifyCanExecuteChanged(); DeleteCommand.NotifyCanExecuteChanged(); } - - [RelayCommand(CanExecute = nameof(CanManageAcl))] - public async Task ManageAcl() + internal async Task InitializeAsync() { - if (SelectedPost is null) + if (!IsLoaded) { - StatusMessage = "Select an existing post before managing ACL."; - return; + await RefreshAsync(); + IsLoaded = true; } - await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true); - } - - [RelayCommand] - public async Task OpenCircles() - { - var circlesVm = ResolveServices().GetRequiredService(); - await ((App)App.Current!).PushPageAsync(circlesVm).ConfigureAwait(true); } } diff --git a/src/PostIt/PostIt/ViewModels/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings.cs index fd87d772..8586fa34 100644 --- a/src/PostIt/PostIt/ViewModels/Settings.cs +++ b/src/PostIt/PostIt/ViewModels/Settings.cs @@ -28,6 +28,9 @@ public partial class Settings : ViewModelBase [ObservableProperty] public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/"; + [ObservableProperty] + public partial string SearchText { get; set; } = string.Empty; + /// /// Catch top-level mutations: the four ObservableProperty /// setters above all funnel through here, and we flip @@ -43,6 +46,7 @@ public partial class Settings : ViewModelBase partial void OnDarkModeChanged(bool value) => MarkDirty(); partial void OnBlogsApiUrlChanged(string value) => MarkDirty(); partial void OnBusinessApiUrlChanged(string value) => MarkDirty(); + partial void OnSearchTextChanged(string value) => MarkDirty(); /// /// Authentication can be reassigned wholesale by @@ -295,6 +299,7 @@ public partial class Settings : ViewModelBase { this.Authentication = settings.Authentication; this.DarkMode = settings.DarkMode; + this.SearchText = settings.SearchText ?? string.Empty; if (!(settings.Authentication is null)) { this.Authentication = new AuthenticationSettings(); @@ -348,6 +353,7 @@ public partial class Settings : ViewModelBase Scopes = AuthenticationSettings.DefaultScopes }; this.DarkMode = false; + this.SearchText = string.Empty; } /// diff --git a/src/PostIt/PostIt/Views/MainPage.axaml b/src/PostIt/PostIt/Views/MainPage.axaml index e44f9f99..0d2f5411 100644 --- a/src/PostIt/PostIt/Views/MainPage.axaml +++ b/src/PostIt/PostIt/Views/MainPage.axaml @@ -29,15 +29,15 @@ VerticalAlignment="Top"> -