feat(12-01): add Base64ToImageSourceConverter, localization keys, and ClientLogoPreview property
- Base64ToImageSourceConverter converts data URI strings to BitmapImage with null-safe error handling - Registered converter in App.xaml as Base64ToImageConverter global resource - Added 9 localization keys (EN+FR) for logo UI labels in Settings and Profile dialogs - Added ClientLogoPreview string property to ProfileManagementViewModel with FormatLogoPreview helper - Updated OnSelectedProfileChanged, BrowseClientLogoAsync, ClearClientLogoAsync, AutoPullClientLogoAsync - 17 tests pass (6 converter + 11 profile VM logo tests) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using SharepointToolbox.Views.Converters;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Tests.Converters;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public class Base64ToImageSourceConverterTests
|
||||||
|
{
|
||||||
|
private readonly Base64ToImageSourceConverter _converter = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Convert_NullValue_ReturnsNull()
|
||||||
|
{
|
||||||
|
var result = _converter.Convert(null, typeof(object), null, CultureInfo.InvariantCulture);
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Convert_EmptyString_ReturnsNull()
|
||||||
|
{
|
||||||
|
var result = _converter.Convert(string.Empty, typeof(object), null, CultureInfo.InvariantCulture);
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Convert_NonStringValue_ReturnsNull()
|
||||||
|
{
|
||||||
|
var result = _converter.Convert(42, typeof(object), null, CultureInfo.InvariantCulture);
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Convert_MalformedString_NoBase64Marker_ReturnsNull()
|
||||||
|
{
|
||||||
|
var result = _converter.Convert("not-a-data-uri", typeof(object), null, CultureInfo.InvariantCulture);
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Convert_InvalidBase64AfterMarker_ReturnsNull()
|
||||||
|
{
|
||||||
|
// Has the marker but invalid base64 content — should not throw
|
||||||
|
var result = _converter.Convert("data:image/png;base64,!!!invalid!!!", typeof(object), null, CultureInfo.InvariantCulture);
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConvertBack_ThrowsNotImplementedException()
|
||||||
|
{
|
||||||
|
Assert.Throws<NotImplementedException>(() =>
|
||||||
|
_converter.ConvertBack(null, typeof(object), null, CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,4 +115,71 @@ public class ProfileManagementViewModelLogoTests : IDisposable
|
|||||||
var persisted = profiles.First(p => p.Name == "TestTenant");
|
var persisted = profiles.First(p => p.Name == "TestTenant");
|
||||||
Assert.Null(persisted.ClientLogo);
|
Assert.Null(persisted.ClientLogo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClientLogoPreview_IsNull_WhenNoProfileSelected()
|
||||||
|
{
|
||||||
|
var vm = CreateViewModel();
|
||||||
|
Assert.Null(vm.ClientLogoPreview);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClientLogoPreview_UpdatesToDataUri_WhenProfileWithLogoSelected()
|
||||||
|
{
|
||||||
|
var vm = CreateViewModel();
|
||||||
|
var profile = new TenantProfile
|
||||||
|
{
|
||||||
|
Name = "WithLogo",
|
||||||
|
TenantUrl = "https://test.sharepoint.com",
|
||||||
|
ClientId = "00000000-0000-0000-0000-000000000002",
|
||||||
|
ClientLogo = new LogoData { Base64 = "dGVzdA==", MimeType = "image/png" }
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.SelectedProfile = profile;
|
||||||
|
|
||||||
|
Assert.Equal("data:image/png;base64,dGVzdA==", vm.ClientLogoPreview);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClientLogoPreview_IsNull_WhenProfileWithoutLogoSelected()
|
||||||
|
{
|
||||||
|
var vm = CreateViewModel();
|
||||||
|
var profile = new TenantProfile
|
||||||
|
{
|
||||||
|
Name = "NoLogo",
|
||||||
|
TenantUrl = "https://test.sharepoint.com",
|
||||||
|
ClientId = "00000000-0000-0000-0000-000000000003"
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.SelectedProfile = profile;
|
||||||
|
|
||||||
|
Assert.Null(vm.ClientLogoPreview);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClearClientLogoCommand_SetsClientLogoPreviewToNull()
|
||||||
|
{
|
||||||
|
var profileService = new ProfileService(new ProfileRepository(_tempFile));
|
||||||
|
var profile = new TenantProfile
|
||||||
|
{
|
||||||
|
Name = "ClearTest",
|
||||||
|
TenantUrl = "https://test.sharepoint.com",
|
||||||
|
ClientId = "00000000-0000-0000-0000-000000000004",
|
||||||
|
ClientLogo = new LogoData { Base64 = "dGVzdA==", MimeType = "image/png" }
|
||||||
|
};
|
||||||
|
await profileService.AddProfileAsync(profile);
|
||||||
|
|
||||||
|
var vm = new ProfileManagementViewModel(
|
||||||
|
profileService,
|
||||||
|
_mockBranding.Object,
|
||||||
|
_graphClientFactory,
|
||||||
|
_logger);
|
||||||
|
|
||||||
|
vm.SelectedProfile = profile;
|
||||||
|
Assert.NotNull(vm.ClientLogoPreview);
|
||||||
|
|
||||||
|
await vm.ClearClientLogoCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Null(vm.ClientLogoPreview);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<conv:EnumBoolConverter x:Key="EnumBoolConverter" />
|
<conv:EnumBoolConverter x:Key="EnumBoolConverter" />
|
||||||
<conv:StringToVisibilityConverter x:Key="StringToVisibilityConverter" />
|
<conv:StringToVisibilityConverter x:Key="StringToVisibilityConverter" />
|
||||||
<conv:ListToStringConverter x:Key="ListToStringConverter" />
|
<conv:ListToStringConverter x:Key="ListToStringConverter" />
|
||||||
|
<conv:Base64ToImageSourceConverter x:Key="Base64ToImageConverter" />
|
||||||
<Style x:Key="RightAlignStyle" TargetType="TextBlock">
|
<Style x:Key="RightAlignStyle" TargetType="TextBlock">
|
||||||
<Setter Property="HorizontalAlignment" Value="Right" />
|
<Setter Property="HorizontalAlignment" Value="Right" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|||||||
@@ -384,4 +384,14 @@
|
|||||||
<data name="stor.chart.bar" xml:space="preserve"><value>Graphique en barres</value></data>
|
<data name="stor.chart.bar" xml:space="preserve"><value>Graphique en barres</value></data>
|
||||||
<data name="stor.chart.toggle" xml:space="preserve"><value>Type de graphique :</value></data>
|
<data name="stor.chart.toggle" xml:space="preserve"><value>Type de graphique :</value></data>
|
||||||
<data name="stor.chart.nodata" xml:space="preserve"><value>Exécutez une analyse pour voir la répartition par type de fichier.</value></data>
|
<data name="stor.chart.nodata" xml:space="preserve"><value>Exécutez une analyse pour voir la répartition par type de fichier.</value></data>
|
||||||
|
<!-- Phase 12: Logo UI -->
|
||||||
|
<data name="settings.logo.title" xml:space="preserve"><value>Logo MSP</value></data>
|
||||||
|
<data name="settings.logo.browse" xml:space="preserve"><value>Importer</value></data>
|
||||||
|
<data name="settings.logo.clear" xml:space="preserve"><value>Effacer</value></data>
|
||||||
|
<data name="settings.logo.nopreview" xml:space="preserve"><value>Aucun logo configuré</value></data>
|
||||||
|
<data name="profile.logo.title" xml:space="preserve"><value>Logo client</value></data>
|
||||||
|
<data name="profile.logo.browse" xml:space="preserve"><value>Importer</value></data>
|
||||||
|
<data name="profile.logo.clear" xml:space="preserve"><value>Effacer</value></data>
|
||||||
|
<data name="profile.logo.autopull" xml:space="preserve"><value>Importer depuis Entra</value></data>
|
||||||
|
<data name="profile.logo.nopreview" xml:space="preserve"><value>Aucun logo configuré</value></data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
@@ -384,4 +384,14 @@
|
|||||||
<data name="stor.chart.bar" xml:space="preserve"><value>Bar Chart</value></data>
|
<data name="stor.chart.bar" xml:space="preserve"><value>Bar Chart</value></data>
|
||||||
<data name="stor.chart.toggle" xml:space="preserve"><value>Chart View:</value></data>
|
<data name="stor.chart.toggle" xml:space="preserve"><value>Chart View:</value></data>
|
||||||
<data name="stor.chart.nodata" xml:space="preserve"><value>Run a storage scan to see file type breakdown.</value></data>
|
<data name="stor.chart.nodata" xml:space="preserve"><value>Run a storage scan to see file type breakdown.</value></data>
|
||||||
|
<!-- Phase 12: Logo UI -->
|
||||||
|
<data name="settings.logo.title" xml:space="preserve"><value>MSP Logo</value></data>
|
||||||
|
<data name="settings.logo.browse" xml:space="preserve"><value>Import</value></data>
|
||||||
|
<data name="settings.logo.clear" xml:space="preserve"><value>Clear</value></data>
|
||||||
|
<data name="settings.logo.nopreview" xml:space="preserve"><value>No logo configured</value></data>
|
||||||
|
<data name="profile.logo.title" xml:space="preserve"><value>Client Logo</value></data>
|
||||||
|
<data name="profile.logo.browse" xml:space="preserve"><value>Import</value></data>
|
||||||
|
<data name="profile.logo.clear" xml:space="preserve"><value>Clear</value></data>
|
||||||
|
<data name="profile.logo.autopull" xml:space="preserve"><value>Pull from Entra</value></data>
|
||||||
|
<data name="profile.logo.nopreview" xml:space="preserve"><value>No logo configured</value></data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ public partial class ProfileManagementViewModel : ObservableObject
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _validationMessage = string.Empty;
|
private string _validationMessage = string.Empty;
|
||||||
|
|
||||||
|
private string? _clientLogoPreview;
|
||||||
|
public string? ClientLogoPreview
|
||||||
|
{
|
||||||
|
get => _clientLogoPreview;
|
||||||
|
private set { _clientLogoPreview = value; OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
public ObservableCollection<TenantProfile> Profiles { get; } = new();
|
public ObservableCollection<TenantProfile> Profiles { get; } = new();
|
||||||
|
|
||||||
public IAsyncRelayCommand AddCommand { get; }
|
public IAsyncRelayCommand AddCommand { get; }
|
||||||
@@ -81,6 +88,7 @@ public partial class ProfileManagementViewModel : ObservableObject
|
|||||||
|
|
||||||
partial void OnSelectedProfileChanged(TenantProfile? value)
|
partial void OnSelectedProfileChanged(TenantProfile? value)
|
||||||
{
|
{
|
||||||
|
ClientLogoPreview = FormatLogoPreview(value?.ClientLogo);
|
||||||
BrowseClientLogoCommand.NotifyCanExecuteChanged();
|
BrowseClientLogoCommand.NotifyCanExecuteChanged();
|
||||||
ClearClientLogoCommand.NotifyCanExecuteChanged();
|
ClearClientLogoCommand.NotifyCanExecuteChanged();
|
||||||
AutoPullClientLogoCommand.NotifyCanExecuteChanged();
|
AutoPullClientLogoCommand.NotifyCanExecuteChanged();
|
||||||
@@ -88,6 +96,9 @@ public partial class ProfileManagementViewModel : ObservableObject
|
|||||||
DeleteCommand.NotifyCanExecuteChanged();
|
DeleteCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? FormatLogoPreview(LogoData? logo)
|
||||||
|
=> logo is not null ? $"data:{logo.MimeType};base64,{logo.Base64}" : null;
|
||||||
|
|
||||||
private void NotifyCommandsCanExecuteChanged()
|
private void NotifyCommandsCanExecuteChanged()
|
||||||
{
|
{
|
||||||
AddCommand.NotifyCanExecuteChanged();
|
AddCommand.NotifyCanExecuteChanged();
|
||||||
@@ -172,6 +183,7 @@ public partial class ProfileManagementViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
var logo = await _brandingService.ImportLogoAsync(dialog.FileName);
|
var logo = await _brandingService.ImportLogoAsync(dialog.FileName);
|
||||||
SelectedProfile.ClientLogo = logo;
|
SelectedProfile.ClientLogo = logo;
|
||||||
|
ClientLogoPreview = FormatLogoPreview(logo);
|
||||||
await _profileService.UpdateProfileAsync(SelectedProfile);
|
await _profileService.UpdateProfileAsync(SelectedProfile);
|
||||||
ValidationMessage = string.Empty;
|
ValidationMessage = string.Empty;
|
||||||
}
|
}
|
||||||
@@ -188,6 +200,7 @@ public partial class ProfileManagementViewModel : ObservableObject
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
SelectedProfile.ClientLogo = null;
|
SelectedProfile.ClientLogo = null;
|
||||||
|
ClientLogoPreview = null;
|
||||||
await _profileService.UpdateProfileAsync(SelectedProfile);
|
await _profileService.UpdateProfileAsync(SelectedProfile);
|
||||||
ValidationMessage = string.Empty;
|
ValidationMessage = string.Empty;
|
||||||
}
|
}
|
||||||
@@ -229,6 +242,7 @@ public partial class ProfileManagementViewModel : ObservableObject
|
|||||||
|
|
||||||
var logo = await _brandingService.ImportLogoFromBytesAsync(bytes);
|
var logo = await _brandingService.ImportLogoFromBytesAsync(bytes);
|
||||||
SelectedProfile.ClientLogo = logo;
|
SelectedProfile.ClientLogo = logo;
|
||||||
|
ClientLogoPreview = FormatLogoPreview(logo);
|
||||||
await _profileService.UpdateProfileAsync(SelectedProfile);
|
await _profileService.UpdateProfileAsync(SelectedProfile);
|
||||||
ValidationMessage = "Client logo pulled from Entra branding.";
|
ValidationMessage = "Client logo pulled from Entra branding.";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Windows.Data;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Views.Converters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a data URI string (e.g. "data:image/png;base64,iVBOR...") to a BitmapImage
|
||||||
|
/// for use with WPF Image controls. Returns null for null, empty, or malformed input.
|
||||||
|
/// </summary>
|
||||||
|
[ValueConversion(typeof(string), typeof(BitmapImage))]
|
||||||
|
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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user