docs(13-02): complete User Directory ViewModel plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
351
.planning/phases/12-branding-ui-views/12-01-PLAN.md
Normal file
351
.planning/phases/12-branding-ui-views/12-01-PLAN.md
Normal file
@@ -0,0 +1,351 @@
|
||||
---
|
||||
phase: 12-branding-ui-views
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- SharepointToolbox/Views/Converters/Base64ToImageSourceConverter.cs
|
||||
- SharepointToolbox/App.xaml
|
||||
- SharepointToolbox/Localization/Strings.resx
|
||||
- SharepointToolbox/Localization/Strings.fr.resx
|
||||
- SharepointToolbox/ViewModels/ProfileManagementViewModel.cs
|
||||
- SharepointToolbox.Tests/Converters/Base64ToImageSourceConverterTests.cs
|
||||
- SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs
|
||||
autonomous: true
|
||||
requirements:
|
||||
- BRAND-02
|
||||
- BRAND-04
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Base64ToImageSourceConverter converts a data URI string to a non-null BitmapImage"
|
||||
- "Base64ToImageSourceConverter returns null for null, empty, or malformed input"
|
||||
- "Converter is registered in App.xaml as a global resource with key Base64ToImageConverter"
|
||||
- "ProfileManagementViewModel exposes ClientLogoPreview (string?) that updates when SelectedProfile changes, and after Browse/Clear/AutoPull commands"
|
||||
- "Localization keys for logo UI exist in both EN and FR resource files"
|
||||
artifacts:
|
||||
- path: "SharepointToolbox/Views/Converters/Base64ToImageSourceConverter.cs"
|
||||
provides: "IValueConverter converting data URI strings to BitmapImage for WPF Image binding"
|
||||
contains: "class Base64ToImageSourceConverter"
|
||||
- path: "SharepointToolbox/App.xaml"
|
||||
provides: "Global converter registration"
|
||||
contains: "Base64ToImageConverter"
|
||||
- path: "SharepointToolbox/ViewModels/ProfileManagementViewModel.cs"
|
||||
provides: "ClientLogoPreview observable property for client logo display"
|
||||
contains: "ClientLogoPreview"
|
||||
- path: "SharepointToolbox.Tests/Converters/Base64ToImageSourceConverterTests.cs"
|
||||
provides: "Unit tests for converter behavior"
|
||||
min_lines: 30
|
||||
key_links:
|
||||
- from: "SharepointToolbox/Views/Converters/Base64ToImageSourceConverter.cs"
|
||||
to: "SharepointToolbox/App.xaml"
|
||||
via: "resource registration"
|
||||
pattern: "Base64ToImageConverter"
|
||||
- from: "SharepointToolbox/ViewModels/ProfileManagementViewModel.cs"
|
||||
to: "SharepointToolbox/Core/Models/LogoData.cs"
|
||||
via: "data URI formatting"
|
||||
pattern: "ClientLogoPreview"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create the Base64ToImageSourceConverter, add localization keys for logo UI, register the converter globally, and add the ClientLogoPreview property to ProfileManagementViewModel.
|
||||
|
||||
Purpose: Provides the infrastructure (converter, localization, ViewModel property) that Plans 02 and 03 need to build the XAML views.
|
||||
|
||||
Output: Converter with tests, localization keys (EN+FR), App.xaml registration, ClientLogoPreview property with test coverage.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@C:/Users/dev/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/12-branding-ui-views/12-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- SettingsViewModel pattern for logo preview (reference for ProfileManagementViewModel) -->
|
||||
|
||||
From SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs:
|
||||
```csharp
|
||||
private string? _mspLogoPreview;
|
||||
public string? MspLogoPreview
|
||||
{
|
||||
get => _mspLogoPreview;
|
||||
private set { _mspLogoPreview = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
// Set in LoadAsync:
|
||||
var mspLogo = await _brandingService.GetMspLogoAsync();
|
||||
MspLogoPreview = mspLogo is not null ? $"data:{mspLogo.MimeType};base64,{mspLogo.Base64}" : null;
|
||||
|
||||
// Set in BrowseMspLogoAsync:
|
||||
MspLogoPreview = $"data:{logo.MimeType};base64,{logo.Base64}";
|
||||
|
||||
// Set in ClearMspLogoAsync:
|
||||
MspLogoPreview = null;
|
||||
```
|
||||
|
||||
From SharepointToolbox/ViewModels/ProfileManagementViewModel.cs (current state):
|
||||
```csharp
|
||||
// BrowseClientLogoAsync sets SelectedProfile.ClientLogo = logo (LogoData)
|
||||
// ClearClientLogoAsync sets SelectedProfile.ClientLogo = null
|
||||
// AutoPullClientLogoAsync sets SelectedProfile.ClientLogo = logo
|
||||
// NO ClientLogoPreview string property exists — this plan adds it
|
||||
```
|
||||
|
||||
From SharepointToolbox/Core/Models/LogoData.cs:
|
||||
```csharp
|
||||
public record LogoData
|
||||
{
|
||||
public string Base64 { get; init; } = string.Empty;
|
||||
public string MimeType { get; init; } = string.Empty;
|
||||
}
|
||||
```
|
||||
|
||||
From SharepointToolbox/Views/Converters/IndentConverter.cs (converter pattern):
|
||||
```csharp
|
||||
public class StringToVisibilityConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> value is string s && !string.IsNullOrEmpty(s) ? Visibility.Visible : Visibility.Collapsed;
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
```
|
||||
|
||||
From SharepointToolbox/App.xaml (converter registration pattern):
|
||||
```xml
|
||||
<conv:StringToVisibilityConverter x:Key="StringToVisibilityConverter" />
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Create Base64ToImageSourceConverter with tests</name>
|
||||
<files>
|
||||
SharepointToolbox/Views/Converters/Base64ToImageSourceConverter.cs,
|
||||
SharepointToolbox.Tests/Converters/Base64ToImageSourceConverterTests.cs
|
||||
</files>
|
||||
<behavior>
|
||||
- Test 1: Convert with null value returns null
|
||||
- Test 2: Convert with empty string returns null
|
||||
- Test 3: Convert with non-string value returns null
|
||||
- Test 4: Convert with valid data URI "data:image/png;base64,{validBase64}" returns a non-null BitmapImage
|
||||
- Test 5: Convert with malformed string (no "base64," prefix) returns null (does not throw)
|
||||
- Test 6: ConvertBack throws NotImplementedException
|
||||
</behavior>
|
||||
<action>
|
||||
1. Create `SharepointToolbox/Views/Converters/Base64ToImageSourceConverter.cs`:
|
||||
```csharp
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Media.Imaging;
|
||||
|
||||
namespace SharepointToolbox.Views.Converters;
|
||||
|
||||
public class Base64ToImageSourceConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not string dataUri || string.IsNullOrEmpty(dataUri))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var marker = "base64,";
|
||||
var idx = dataUri.IndexOf(marker, StringComparison.Ordinal);
|
||||
if (idx < 0) return null;
|
||||
|
||||
var base64 = dataUri[(idx + marker.Length)..];
|
||||
var bytes = System.Convert.FromBase64String(base64);
|
||||
|
||||
var image = new BitmapImage();
|
||||
using var ms = new MemoryStream(bytes);
|
||||
image.BeginInit();
|
||||
image.CacheOption = BitmapCacheOption.OnLoad;
|
||||
image.StreamSource = ms;
|
||||
image.EndInit();
|
||||
image.Freeze();
|
||||
return image;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
```
|
||||
Key decisions:
|
||||
- Parses data URI by finding "base64," marker — works with any mime type
|
||||
- `BitmapCacheOption.OnLoad` ensures the stream can be disposed immediately
|
||||
- `Freeze()` makes the image cross-thread safe (required for WPF binding)
|
||||
- Catches all exceptions to avoid binding errors — returns null on failure
|
||||
|
||||
2. Create `SharepointToolbox.Tests/Converters/Base64ToImageSourceConverterTests.cs`:
|
||||
Write tests FIRST (RED), then verify GREEN.
|
||||
Use `[Trait("Category", "Unit")]` per project convention.
|
||||
Note: Tests that create BitmapImage need `[STAThread]` or run on STA thread. Use xUnit's `[WpfFact]` from `Xunit.StaFact` if available, or mark tests with `[Fact]` and handle STA requirement.
|
||||
For the valid data URI test, use a minimal valid 1x1 PNG base64: `iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==`
|
||||
|
||||
IMPORTANT: Check if `Xunit.StaFact` NuGet package is referenced in the test project. If not, the BitmapImage tests may need to be skipped or use a workaround (run converter logic that doesn't need STA for null/empty cases, skip the BitmapImage creation test if STA not available).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>dotnet build --no-restore -warnaserror && dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~Base64ToImageSourceConverter" --no-build -q</automated>
|
||||
</verify>
|
||||
<done>Converter class exists, handles all edge cases without throwing, tests pass.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Register converter in App.xaml</name>
|
||||
<files>
|
||||
SharepointToolbox/App.xaml
|
||||
</files>
|
||||
<behavior>
|
||||
- App.xaml contains a Base64ToImageSourceConverter resource with key "Base64ToImageConverter"
|
||||
</behavior>
|
||||
<action>
|
||||
1. In `SharepointToolbox/App.xaml`, add inside `<Application.Resources>`:
|
||||
```xml
|
||||
<conv:Base64ToImageSourceConverter x:Key="Base64ToImageConverter" />
|
||||
```
|
||||
Place it after the existing converter registrations.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>dotnet build --no-restore -warnaserror</automated>
|
||||
</verify>
|
||||
<done>Converter is globally available via StaticResource Base64ToImageConverter.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Add localization keys for logo UI (EN + FR)</name>
|
||||
<files>
|
||||
SharepointToolbox/Localization/Strings.resx,
|
||||
SharepointToolbox/Localization/Strings.fr.resx
|
||||
</files>
|
||||
<behavior>
|
||||
- Both resx files contain matching keys for logo UI labels
|
||||
</behavior>
|
||||
<action>
|
||||
1. Add to `Strings.resx` (EN):
|
||||
- `settings.logo.title` = "MSP Logo"
|
||||
- `settings.logo.browse` = "Import"
|
||||
- `settings.logo.clear` = "Clear"
|
||||
- `settings.logo.nopreview` = "No logo configured"
|
||||
- `profile.logo.title` = "Client Logo"
|
||||
- `profile.logo.browse` = "Import"
|
||||
- `profile.logo.clear` = "Clear"
|
||||
- `profile.logo.autopull` = "Pull from Entra"
|
||||
- `profile.logo.nopreview` = "No logo configured"
|
||||
|
||||
2. Add to `Strings.fr.resx` (FR):
|
||||
- `settings.logo.title` = "Logo MSP"
|
||||
- `settings.logo.browse` = "Importer"
|
||||
- `settings.logo.clear` = "Effacer"
|
||||
- `settings.logo.nopreview` = "Aucun logo configuré"
|
||||
- `profile.logo.title` = "Logo client"
|
||||
- `profile.logo.browse` = "Importer"
|
||||
- `profile.logo.clear` = "Effacer"
|
||||
- `profile.logo.autopull` = "Importer depuis Entra"
|
||||
- `profile.logo.nopreview` = "Aucun logo configuré"
|
||||
</action>
|
||||
<verify>
|
||||
<automated>dotnet build --no-restore -warnaserror</automated>
|
||||
</verify>
|
||||
<done>All 9 localization keys exist in both EN and FR resource files.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Add ClientLogoPreview property to ProfileManagementViewModel</name>
|
||||
<files>
|
||||
SharepointToolbox/ViewModels/ProfileManagementViewModel.cs,
|
||||
SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs
|
||||
</files>
|
||||
<behavior>
|
||||
- ProfileManagementViewModel exposes ClientLogoPreview (string?) property
|
||||
- ClientLogoPreview updates to data URI when SelectedProfile changes and has a ClientLogo
|
||||
- ClientLogoPreview updates to null when SelectedProfile is null or has no ClientLogo
|
||||
- BrowseClientLogoAsync updates ClientLogoPreview after successful import
|
||||
- ClearClientLogoAsync sets ClientLogoPreview to null
|
||||
- AutoPullClientLogoAsync updates ClientLogoPreview after successful pull
|
||||
</behavior>
|
||||
<action>
|
||||
1. Add to `ProfileManagementViewModel.cs`:
|
||||
```csharp
|
||||
private string? _clientLogoPreview;
|
||||
public string? ClientLogoPreview
|
||||
{
|
||||
get => _clientLogoPreview;
|
||||
private set { _clientLogoPreview = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
private static string? FormatLogoPreview(LogoData? logo)
|
||||
=> logo is not null ? $"data:{logo.MimeType};base64,{logo.Base64}" : null;
|
||||
```
|
||||
|
||||
2. Update `OnSelectedProfileChanged` to refresh preview:
|
||||
```csharp
|
||||
partial void OnSelectedProfileChanged(TenantProfile? value)
|
||||
{
|
||||
ClientLogoPreview = FormatLogoPreview(value?.ClientLogo);
|
||||
// ... existing NotifyCanExecuteChanged calls ...
|
||||
}
|
||||
```
|
||||
|
||||
3. Update `BrowseClientLogoAsync` — after `SelectedProfile.ClientLogo = logo;` add:
|
||||
```csharp
|
||||
ClientLogoPreview = FormatLogoPreview(logo);
|
||||
```
|
||||
|
||||
4. Update `ClearClientLogoAsync` — after `SelectedProfile.ClientLogo = null;` add:
|
||||
```csharp
|
||||
ClientLogoPreview = null;
|
||||
```
|
||||
|
||||
5. Update `AutoPullClientLogoAsync` — after `SelectedProfile.ClientLogo = logo;` add:
|
||||
```csharp
|
||||
ClientLogoPreview = FormatLogoPreview(logo);
|
||||
```
|
||||
|
||||
6. Update existing tests in `ProfileManagementViewModelLogoTests.cs`:
|
||||
- Add test: ClientLogoPreview is null when no profile selected
|
||||
- Add test: ClientLogoPreview updates when SelectedProfile with logo is selected
|
||||
- Add test: ClearClientLogoAsync sets ClientLogoPreview to null
|
||||
</action>
|
||||
<verify>
|
||||
<automated>dotnet build --no-restore -warnaserror && dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~ProfileManagementViewModel" --no-build -q</automated>
|
||||
</verify>
|
||||
<done>ClientLogoPreview property exists and stays in sync with SelectedProfile.ClientLogo across all mutations. Tests pass.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
dotnet build --no-restore -warnaserror
|
||||
dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~Base64ToImageSourceConverter|FullyQualifiedName~ProfileManagementViewModel" --no-build -q
|
||||
```
|
||||
Both commands must pass with zero failures.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Base64ToImageSourceConverter converts data URI strings to BitmapImage, returns null on bad input
|
||||
- Converter registered in App.xaml as "Base64ToImageConverter"
|
||||
- 9 localization keys present in both Strings.resx and Strings.fr.resx
|
||||
- ProfileManagementViewModel.ClientLogoPreview stays in sync with SelectedProfile.ClientLogo
|
||||
- All tests pass, build succeeds with zero warnings
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/12-branding-ui-views/12-01-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user