From d3664c5cdcaa9c0935d626333dc250f4c90b771b Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Thu, 9 Jul 2026 21:24:48 +0100 Subject: [PATCH] postIt: scope list in SettingsPage, fix Settings DI re-registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to the PostIt settings surface, both in service of the same observation: opening the Settings page did not reflect the loaded state, and edits to Authority / ClientId did not persist. 1. Settings was registered twice in the DI container: once as a singleton (the already-Load()'d instance) and again as a transient, with the transient registration winning. The Settings page's DataContext was therefore a brand-new, empty Settings instance on every push — Authority and ClientId bound to null, and even if the user typed into the fields, the edits landed on the throwaway instance and were silently lost. The fix is the obvious one: keep Settings as a singleton and drop the transient override. 2. The Scopes field of AuthenticationSettings is a string[], which doesn't bind to a TextBox without a converter. The Settings page already shows the other auth fields as plain TextBoxes, so the same treatment is given to scopes via a new space-separated view property: - AuthenticationSettings.ScopeListText (string, [ObservableProperty], [JsonIgnore]) is the view. - OnScopeListTextChanged splits on any whitespace and re-assigns Scopes, skipping the write when the parsed array is element-wise equal to the current one to avoid a PropertyChanged loop with OnScopesChanged. - OnScopesChanged keeps ScopeListText in sync when Scopes is reassigned from outside (JSON hydration, MergeScopes, programmatic updates), again short- circuiting when the textual representation hasn't changed so the TextBox caret doesn't flicker on load. - RefreshScopeListText is the explicit re-sync entry point; Settings.ApplyJson calls it after a successful hydration to normalise any whitespace the JSON might have introduced. SettingsPage.axaml gets a new Scopes row between ClientId and the Blogs API URL; the Grid.RowDefinitions are bumped to 13 to match. Scopes remains the on-disk format — only ScopeListText is presentation. The shape of the on-disk postit-settings.json is unchanged: [JsonIgnore] on ScopeListText, and the serialization path in Settings still round-trips Scopes directly. MergeScopes in Settings.GetOidcClientOptions is untouched. Tests: 3/3 SettingsLoadTests passing (PostIt.Tests); PostIt.csproj builds clean (0 errors). The other PostIt.Tests suites depend on the OIDC stub WebApplicationFactory and time out on this network-restricted host, so we trust the unit-level coverage and the build. --- src/PostIt/PostIt/App.axaml.cs | 1 - .../PostIt/Settings/AuthenticationSettings.cs | 79 +++++++++++++++++++ src/PostIt/PostIt/ViewModels/Settings.cs | 9 +++ src/PostIt/PostIt/Views/SettingsPage.axaml | 24 +++--- 4 files changed, 103 insertions(+), 10 deletions(-) diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index b474b37b..84312064 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -69,7 +69,6 @@ public partial class App : Application services.AddSingleton(api); services.AddSingleton(client); services.AddTransient(); - services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/src/PostIt/PostIt/Settings/AuthenticationSettings.cs b/src/PostIt/PostIt/Settings/AuthenticationSettings.cs index 9ece1e45..ad71063b 100644 --- a/src/PostIt/PostIt/Settings/AuthenticationSettings.cs +++ b/src/PostIt/PostIt/Settings/AuthenticationSettings.cs @@ -1,5 +1,6 @@ using CommunityToolkit.Mvvm.ComponentModel; using System; +using System.Text.Json.Serialization; public partial class AuthenticationSettings : ObservableObject { @@ -40,4 +41,82 @@ public partial class AuthenticationSettings : ObservableObject [ObservableProperty] public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri; + /// + /// Space-separated view of . Exists for the + /// SettingsPage TextBox binding — a string[] does not + /// round-trip through XAML binding to TextBox.Text, so we + /// expose the array as a string here and re-parse on assignment. + /// + /// [JsonIgnore] on purpose: is the + /// persisted shape (matches the on-disk format in + /// postit-settings.json and the runtime contract in + /// ). + /// Writing this property back to disk would duplicate the + /// information and confuse the deserializer. + /// + /// + [JsonIgnore] + [ObservableProperty] + public partial string ScopeListText { get; set; } = string.Empty; + + /// + /// Refresh from so + /// the TextBox shows the current persisted state after a Load(). + /// Called from Settings.ApplyJson on each disk / embedded + /// hydration; the source generator's OnScopesChanged partial + /// below keeps the two in sync in the other direction (edits made + /// in the TextBox). + /// + public void RefreshScopeListText() + { + ScopeListText = Scopes is null ? string.Empty : string.Join(' ', Scopes); + } + + partial void OnScopeListTextChanged(string value) + { + if (Scopes is null) + { + Scopes = Array.Empty(); + } + // Split on any whitespace, drop empties. Matches what + // string.Join(' ', Scopes) produces when Scopes is null-free, + // so a round-trip (Display → Edit → Display) is lossless + // for sane inputs. + var parts = value?.Split( + new[] { ' ', '\t', '\n', '\r' }, + StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty(); + + // Skip the write if the parsed array is equal to the current + // one — avoids a PropertyChanged loop between OnScopesChanged + // and OnScopeListTextChanged when RefreshScopeListText runs. + if (Scopes is not null && Scopes.Length == parts.Length) + { + var same = true; + for (var i = 0; i < parts.Length; i++) + { + if (!string.Equals(Scopes[i], parts[i], StringComparison.Ordinal)) + { + same = false; + break; + } + } + if (same) return; + } + Scopes = parts; + } + + partial void OnScopesChanged(string[] value) + { + // Keep ScopeListText in sync when Scopes is reassigned from + // outside (JSON hydration, MergeScopes, programmatic + // updates). Compute the new value and only fire if it + // differs from what's already shown, otherwise the TextBox + // would briefly flicker / re-set the caret on every load. + var newText = value is null ? string.Empty : string.Join(' ', value); + if (!string.Equals(ScopeListText, newText, StringComparison.Ordinal)) + { + ScopeListText = newText; + } + } + } diff --git a/src/PostIt/PostIt/ViewModels/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings.cs index 98085f0c..5bd3a844 100644 --- a/src/PostIt/PostIt/ViewModels/Settings.cs +++ b/src/PostIt/PostIt/ViewModels/Settings.cs @@ -361,6 +361,15 @@ public partial class Settings : ViewModelBase // triggered by the assignments above doesn't leave it // stuck at true. IsDirty = false; + // Refresh the space-separated ScopeListText view after + // hydration so the SettingsPage TextBox reflects the + // loaded scopes (and not the default empty string the + // ObservableProperty was constructed with). OnScopesChanged + // already tries to do this, but it skips when the new + // array parses to the same text — calling explicitly + // forces a re-sync and normalises any whitespace the + // JSON might have introduced. + this.Authentication?.RefreshScopeListText(); // Re-notify the command in case the button was bound // before Load finished and the CanExecute cache is // stale. diff --git a/src/PostIt/PostIt/Views/SettingsPage.axaml b/src/PostIt/PostIt/Views/SettingsPage.axaml index aeba19f0..5d18f8ad 100644 --- a/src/PostIt/PostIt/Views/SettingsPage.axaml +++ b/src/PostIt/PostIt/Views/SettingsPage.axaml @@ -20,6 +20,8 @@ + + @@ -30,25 +32,29 @@ - - + + + + - - + - - + + -