From f98ca60990bbe3433f5fb8835720c69610fef529 Mon Sep 17 00:00:00 2001 From: Dev Date: Thu, 2 Apr 2026 14:06:39 +0200 Subject: [PATCH] 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?) - 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) --- .../ViewModels/PermissionsViewModelTests.cs | 3 +- .../ViewModels/Tabs/PermissionsViewModel.cs | 246 +++++++++++++++++- .../Views/Dialogs/SitePickerDialog.xaml | 60 +++++ .../Views/Dialogs/SitePickerDialog.xaml.cs | 127 +++++++++ 4 files changed, 425 insertions(+), 11 deletions(-) create mode 100644 SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml create mode 100644 SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml.cs diff --git a/SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs b/SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs index 4545047..8ad8f79 100644 --- a/SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs +++ b/SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs @@ -6,6 +6,7 @@ using Microsoft.SharePoint.Client; using Moq; using SharepointToolbox.Core.Models; using SharepointToolbox.Services; +using SharepointToolbox.ViewModels; using SharepointToolbox.ViewModels.Tabs; namespace SharepointToolbox.Tests.ViewModels; @@ -40,7 +41,7 @@ public class PermissionsViewModelTests mockPermissionsService.Object, mockSiteListService.Object, mockSessionManager.Object, - new NullLogger()); + new NullLogger()); // Set up two site URLs via SelectedSites vm.SelectedSites.Add(new SiteInfo("https://tenant1.sharepoint.com/sites/alpha", "Alpha")); diff --git a/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs b/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs index 045e217..5415b31 100644 --- a/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs @@ -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; /// -/// 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. /// 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 _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 _results = new(); + + // ── Commands ──────────────────────────────────────────────────────────── + + public IAsyncRelayCommand ExportCsvCommand { get; } + public IAsyncRelayCommand ExportHtmlCommand { get; } + public RelayCommand OpenSitePickerCommand { get; } + + // ── Multi-site ────────────────────────────────────────────────────────── public ObservableCollection SelectedSites { get; } = new(); - public ObservableCollection Results { get; private set; } = new(); + + // ── Dialog factory (set by View layer — keeps Window out of ViewModel) ── + + /// + /// Factory function set by the View layer to open the SitePickerDialog. + /// Returns the opened Window; ViewModel calls ShowDialog() on it. + /// + public Func? OpenSitePickerDialog { get; set; } + + // ── Current tenant profile (received via WeakReferenceMessenger) ──────── internal TenantProfile? _currentProfile; + // ── Constructors ──────────────────────────────────────────────────────── + + /// + /// Full constructor — used by DI and production code. + /// public PermissionsViewModel( IPermissionsService permissionsService, ISiteListService siteListService, ISessionManager sessionManager, - ILogger logger) + CsvExportService csvExportService, + HtmlExportService htmlExportService, + ILogger 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; + /// + /// Test constructor — omits export services (not needed for unit tests). + /// + internal PermissionsViewModel( + IPermissionsService permissionsService, + ISiteListService siteListService, + ISessionManager sessionManager, + ILogger logger) + : base(logger) + { + _permissionsService = permissionsService; + _siteListService = siteListService; + _sessionManager = sessionManager; + _csvExportService = null; + _htmlExportService = null; + _logger = logger; - internal Task TestRunOperationAsync(CancellationToken ct, IProgress 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 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 { 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(); + 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(allEntries); + }); + } + else + { + Results = new ObservableCollection(allEntries); + } + + ExportCsvCommand.NotifyCanExecuteChanged(); + ExportHtmlCommand.NotifyCanExecuteChanged(); + } + + // ── Tenant switching ───────────────────────────────────────────────────── + + protected override void OnTenantSwitched(TenantProfile profile) + { + _currentProfile = profile; + Results = new ObservableCollection(); + SiteUrl = string.Empty; + SelectedSites.Clear(); + ExportCsvCommand.NotifyCanExecuteChanged(); + ExportHtmlCommand.NotifyCanExecuteChanged(); + } + + // ── Internal helpers ───────────────────────────────────────────────────── + + /// Sets the current tenant profile (for test injection). + internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile; + + /// Exposes RunOperationAsync for unit tests (internal + InternalsVisibleTo). + internal Task TestRunOperationAsync(CancellationToken ct, IProgress 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 + } } } diff --git a/SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml b/SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml new file mode 100644 index 0000000..d630f0e --- /dev/null +++ b/SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +