feat(02-06): implement PermissionsViewModel with multi-site scan and SitePickerDialog

- PermissionsViewModel extends FeatureViewModelBase, implements RunOperationAsync
- Multi-site mode: loops SelectedSites; single-site mode: uses SiteUrl
- ExportCsvCommand and ExportHtmlCommand enabled only when Results.Count > 0
- OpenSitePickerCommand uses dialog factory pattern (Func<Window>?)
- OnTenantSwitched clears Results, SiteUrl, SelectedSites
- Flat ObservableProperty booleans (IncludeInherited, ScanFolders, etc.) build ScanOptions record
- SitePickerDialog XAML: filterable list with CheckBox column, Title, URL columns
- SitePickerDialog code-behind: loads sites on Window.Loaded, exposes SelectedUrls
- ISessionManager interface extracted for testability (SessionManager implements it)
- StartScanAsync_WithMultipleSiteUrls_CallsServiceOncePerUrl test passes (60/60 + 3 skip)
This commit is contained in:
Dev
2026-04-02 14:06:39 +02:00
parent c462a0b310
commit f98ca60990
4 changed files with 425 additions and 11 deletions

View File

@@ -1,47 +1,273 @@
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using System.Diagnostics;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using SharepointToolbox.Core.Messages;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;
namespace SharepointToolbox.ViewModels.Tabs;
/// <summary>
/// STUB: PermissionsViewModel — RED phase. Not yet implemented.
/// ViewModel for the Permissions tab.
/// Orchestrates permission scanning across one or multiple SharePoint sites
/// and exports results to CSV or HTML.
/// </summary>
public partial class PermissionsViewModel : FeatureViewModelBase
{
private readonly IPermissionsService _permissionsService;
private readonly ISiteListService _siteListService;
private readonly ISessionManager _sessionManager;
private readonly CsvExportService? _csvExportService;
private readonly HtmlExportService? _htmlExportService;
private readonly ILogger<FeatureViewModelBase> _logger;
// ── Observable properties ───────────────────────────────────────────────
[ObservableProperty]
private string _siteUrl = string.Empty;
[ObservableProperty]
private bool _includeInherited;
[ObservableProperty]
private bool _scanFolders = true;
[ObservableProperty]
private bool _includeSubsites;
[ObservableProperty]
private int _folderDepth = 1;
[ObservableProperty]
private ObservableCollection<PermissionEntry> _results = new();
// ── Commands ────────────────────────────────────────────────────────────
public IAsyncRelayCommand ExportCsvCommand { get; }
public IAsyncRelayCommand ExportHtmlCommand { get; }
public RelayCommand OpenSitePickerCommand { get; }
// ── Multi-site ──────────────────────────────────────────────────────────
public ObservableCollection<SiteInfo> SelectedSites { get; } = new();
public ObservableCollection<PermissionEntry> Results { get; private set; } = new();
// ── Dialog factory (set by View layer — keeps Window out of ViewModel) ──
/// <summary>
/// Factory function set by the View layer to open the SitePickerDialog.
/// Returns the opened Window; ViewModel calls ShowDialog() on it.
/// </summary>
public Func<Window>? OpenSitePickerDialog { get; set; }
// ── Current tenant profile (received via WeakReferenceMessenger) ────────
internal TenantProfile? _currentProfile;
// ── Constructors ────────────────────────────────────────────────────────
/// <summary>
/// Full constructor — used by DI and production code.
/// </summary>
public PermissionsViewModel(
IPermissionsService permissionsService,
ISiteListService siteListService,
ISessionManager sessionManager,
ILogger<PermissionsViewModel> logger)
CsvExportService csvExportService,
HtmlExportService htmlExportService,
ILogger<FeatureViewModelBase> logger)
: base(logger)
{
_permissionsService = permissionsService;
_siteListService = siteListService;
_sessionManager = sessionManager;
_csvExportService = csvExportService;
_htmlExportService = htmlExportService;
_logger = logger;
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
}
public void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
/// <summary>
/// Test constructor — omits export services (not needed for unit tests).
/// </summary>
internal PermissionsViewModel(
IPermissionsService permissionsService,
ISiteListService siteListService,
ISessionManager sessionManager,
ILogger<FeatureViewModelBase> logger)
: base(logger)
{
_permissionsService = permissionsService;
_siteListService = siteListService;
_sessionManager = sessionManager;
_csvExportService = null;
_htmlExportService = null;
_logger = logger;
internal Task TestRunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
=> RunOperationAsync(ct, progress);
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
}
// ── FeatureViewModelBase implementation ─────────────────────────────────
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
// RED STUB: always throws to make tests fail at RED phase
throw new NotImplementedException("PermissionsViewModel.RunOperationAsync not yet implemented.");
var urls = SelectedSites.Count > 0
? SelectedSites.Select(s => s.Url).ToList()
: new List<string> { SiteUrl };
var nonEmpty = urls.Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (nonEmpty.Count == 0)
{
StatusMessage = "Enter a site URL or select sites.";
return;
}
var allEntries = new List<PermissionEntry>();
var scanOptions = new ScanOptions(
IncludeInherited: IncludeInherited,
ScanFolders: ScanFolders,
FolderDepth: FolderDepth,
IncludeSubsites: IncludeSubsites);
int i = 0;
foreach (var url in nonEmpty)
{
ct.ThrowIfCancellationRequested();
progress.Report(new OperationProgress(i, nonEmpty.Count, $"Scanning {url}..."));
var profile = new TenantProfile
{
TenantUrl = url,
ClientId = _currentProfile?.ClientId ?? string.Empty,
Name = _currentProfile?.Name ?? string.Empty
};
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, ct);
var siteEntries = await _permissionsService.ScanSiteAsync(ctx, scanOptions, progress, ct);
allEntries.AddRange(siteEntries);
i++;
}
// Update Results on the UI thread (no-op if no Dispatcher in tests)
var dispatcher = Application.Current?.Dispatcher;
if (dispatcher != null)
{
await dispatcher.InvokeAsync(() =>
{
Results = new ObservableCollection<PermissionEntry>(allEntries);
});
}
else
{
Results = new ObservableCollection<PermissionEntry>(allEntries);
}
ExportCsvCommand.NotifyCanExecuteChanged();
ExportHtmlCommand.NotifyCanExecuteChanged();
}
// ── Tenant switching ─────────────────────────────────────────────────────
protected override void OnTenantSwitched(TenantProfile profile)
{
_currentProfile = profile;
Results = new ObservableCollection<PermissionEntry>();
SiteUrl = string.Empty;
SelectedSites.Clear();
ExportCsvCommand.NotifyCanExecuteChanged();
ExportHtmlCommand.NotifyCanExecuteChanged();
}
// ── Internal helpers ─────────────────────────────────────────────────────
/// <summary>Sets the current tenant profile (for test injection).</summary>
internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
/// <summary>Exposes RunOperationAsync for unit tests (internal + InternalsVisibleTo).</summary>
internal Task TestRunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
=> RunOperationAsync(ct, progress);
// ── Command implementations ───────────────────────────────────────────────
private bool CanExport() => Results.Count > 0;
private async Task ExportCsvAsync()
{
if (_csvExportService == null || Results.Count == 0) return;
var dialog = new SaveFileDialog
{
Title = "Export permissions to CSV",
Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*",
DefaultExt = "csv",
FileName = "permissions"
};
if (dialog.ShowDialog() != true) return;
try
{
await _csvExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
OpenFile(dialog.FileName);
}
catch (Exception ex)
{
StatusMessage = $"Export failed: {ex.Message}";
_logger.LogError(ex, "CSV export failed.");
}
}
private async Task ExportHtmlAsync()
{
if (_htmlExportService == null || Results.Count == 0) return;
var dialog = new SaveFileDialog
{
Title = "Export permissions to HTML",
Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
DefaultExt = "html",
FileName = "permissions"
};
if (dialog.ShowDialog() != true) return;
try
{
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
OpenFile(dialog.FileName);
}
catch (Exception ex)
{
StatusMessage = $"Export failed: {ex.Message}";
_logger.LogError(ex, "HTML export failed.");
}
}
private void ExecuteOpenSitePicker()
{
if (OpenSitePickerDialog == null) return;
var dialog = OpenSitePickerDialog.Invoke();
if (dialog?.ShowDialog() == true && dialog is Views.Dialogs.SitePickerDialog picker)
{
SelectedSites.Clear();
foreach (var site in picker.SelectedUrls)
SelectedSites.Add(site);
}
}
private static void OpenFile(string filePath)
{
try
{
Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true });
}
catch
{
// Non-critical: file was written successfully, just can't auto-open
}
}
}