postIt: scope list in SettingsPage, fix Settings DI re-registration
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.
This commit is contained in:
parent
375e6482a6
commit
d3664c5cdc
4 changed files with 103 additions and 10 deletions
|
|
@ -69,7 +69,6 @@ public partial class App : Application
|
||||||
services.AddSingleton(api);
|
services.AddSingleton(api);
|
||||||
services.AddSingleton(client);
|
services.AddSingleton(client);
|
||||||
services.AddTransient<MainPageViewModel>();
|
services.AddTransient<MainPageViewModel>();
|
||||||
services.AddTransient<Settings>();
|
|
||||||
services.AddTransient<HomePageViewModel>();
|
services.AddTransient<HomePageViewModel>();
|
||||||
services.AddTransient<SignaturePageViewModel>();
|
services.AddTransient<SignaturePageViewModel>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
public partial class AuthenticationSettings : ObservableObject
|
public partial class AuthenticationSettings : ObservableObject
|
||||||
{
|
{
|
||||||
|
|
@ -40,4 +41,82 @@ public partial class AuthenticationSettings : ObservableObject
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri;
|
public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Space-separated view of <see cref="Scopes"/>. Exists for the
|
||||||
|
/// <c>SettingsPage</c> TextBox binding — a <c>string[]</c> does not
|
||||||
|
/// round-trip through XAML binding to <c>TextBox.Text</c>, so we
|
||||||
|
/// expose the array as a string here and re-parse on assignment.
|
||||||
|
/// <para>
|
||||||
|
/// <c>[JsonIgnore]</c> on purpose: <see cref="Scopes"/> is the
|
||||||
|
/// persisted shape (matches the on-disk format in
|
||||||
|
/// <c>postit-settings.json</c> and the runtime contract in
|
||||||
|
/// <see cref="PostIt.ViewModels.Settings.GetOidcClientOptions"/>).
|
||||||
|
/// Writing this property back to disk would duplicate the
|
||||||
|
/// information and confuse the deserializer.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string ScopeListText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refresh <see cref="ScopeListText"/> from <see cref="Scopes"/> so
|
||||||
|
/// the TextBox shows the current persisted state after a Load().
|
||||||
|
/// Called from <c>Settings.ApplyJson</c> on each disk / embedded
|
||||||
|
/// hydration; the source generator's <c>OnScopesChanged</c> partial
|
||||||
|
/// below keeps the two in sync in the other direction (edits made
|
||||||
|
/// in the TextBox).
|
||||||
|
/// </summary>
|
||||||
|
public void RefreshScopeListText()
|
||||||
|
{
|
||||||
|
ScopeListText = Scopes is null ? string.Empty : string.Join(' ', Scopes);
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnScopeListTextChanged(string value)
|
||||||
|
{
|
||||||
|
if (Scopes is null)
|
||||||
|
{
|
||||||
|
Scopes = Array.Empty<string>();
|
||||||
|
}
|
||||||
|
// 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<string>();
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -361,6 +361,15 @@ public partial class Settings : ViewModelBase
|
||||||
// triggered by the assignments above doesn't leave it
|
// triggered by the assignments above doesn't leave it
|
||||||
// stuck at true.
|
// stuck at true.
|
||||||
IsDirty = false;
|
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
|
// Re-notify the command in case the button was bound
|
||||||
// before Load finished and the CanExecute cache is
|
// before Load finished and the CanExecute cache is
|
||||||
// stale.
|
// stale.
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<TextBlock Grid.Row="0" Text="Authority"/>
|
<TextBlock Grid.Row="0" Text="Authority"/>
|
||||||
|
|
@ -30,25 +32,29 @@
|
||||||
<TextBox Grid.Row="3" x:Name="ClientIdTextBox"
|
<TextBox Grid.Row="3" x:Name="ClientIdTextBox"
|
||||||
Text="{Binding Authentication.ClientId, Mode=TwoWay}"/>
|
Text="{Binding Authentication.ClientId, Mode=TwoWay}"/>
|
||||||
|
|
||||||
<TextBlock Grid.Row="4" Text="Blogs API URL"/>
|
<TextBlock Grid.Row="4" Text="Scopes (space-separated)"/>
|
||||||
<TextBox Grid.Row="5" x:Name="BlogsApiUrlTextBox"
|
<TextBox Grid.Row="5" x:Name="ScopesTextBox"
|
||||||
|
Text="{Binding Authentication.ScopeListText, Mode=TwoWay}"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="6" Text="Blogs API URL"/>
|
||||||
|
<TextBox Grid.Row="7" x:Name="BlogsApiUrlTextBox"
|
||||||
Text="{Binding BlogsApiUrl, Mode=TwoWay}"/>
|
Text="{Binding BlogsApiUrl, Mode=TwoWay}"/>
|
||||||
|
|
||||||
<TextBlock Grid.Row="6" Text="Business API URL"/>
|
<TextBlock Grid.Row="8" Text="Business API URL"/>
|
||||||
<TextBox Grid.Row="7" x:Name="BusinessApiUrlTextBox"
|
<TextBox Grid.Row="9" x:Name="BusinessApiUrlTextBox"
|
||||||
Text="{Binding BusinessApiUrl, Mode=TwoWay}"/>
|
Text="{Binding BusinessApiUrl, Mode=TwoWay}"/>
|
||||||
|
|
||||||
<TextBlock Grid.Row="8" Text="Dark mode"/>
|
<TextBlock Grid.Row="10" Text="Dark mode"/>
|
||||||
<CheckBox Grid.Row="9" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
|
<CheckBox Grid.Row="11" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
|
||||||
|
|
||||||
<!-- Sauver: bound to the SaveCommand on the Settings VM, with
|
<!-- Sauver: bound to the SaveCommand on the Settings VM, with
|
||||||
IsEnabled driven by the inverse of IsDirty so the button
|
IsEnabled driven by the inverse of IsDirty so the button
|
||||||
auto-disables when there's nothing to persist. -->
|
auto-disables when there's nothing to persist. -->
|
||||||
<Button Grid.Row="10" Content="Sauver"
|
<Button Grid.Row="12" Content="Sauver"
|
||||||
HorizontalAlignment="Right"
|
HorizontalAlignment="Right"
|
||||||
Margin="0,12,0,0"
|
Margin="0,12,0,0"
|
||||||
Command="{Binding SaveCommand}"
|
Command="{Binding Save}"
|
||||||
IsEnabled="{Binding !IsDirty}"/>
|
IsEnabled="{Binding IsDirty}"/>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue