--- phase: 03 plan: 07 title: StorageViewModel + StorageView XAML + DI Wiring status: pending wave: 3 depends_on: - 03-03 - 03-06 files_modified: - SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs - SharepointToolbox/Views/Tabs/StorageView.xaml - SharepointToolbox/Views/Tabs/StorageView.xaml.cs - SharepointToolbox/App.xaml.cs - SharepointToolbox/MainWindow.xaml - SharepointToolbox/MainWindow.xaml.cs autonomous: true requirements: - STOR-01 - STOR-02 - STOR-03 - STOR-04 - STOR-05 must_haves: truths: - "StorageView appears in the Storage tab (replaces FeatureTabBase stub) when the app runs" - "User can enter a site URL, set folder depth (0 = library root, or N levels), check per-library breakdown, and click Generate Metrics" - "DataGrid displays StorageNode rows with library name indented by IndentLevel, file count, total size, version size, last modified" - "Export buttons are enabled after a successful scan and disabled when Results is empty" - "Never modify ObservableCollection from a background thread — accumulate in List on background, then Dispatcher.InvokeAsync" - "StorageViewModel never stores ClientContext — it calls ISessionManager.GetOrCreateContextAsync at operation start" artifacts: - path: "SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs" provides: "Storage tab ViewModel (IStorageService orchestration)" exports: ["StorageViewModel"] - path: "SharepointToolbox/Views/Tabs/StorageView.xaml" provides: "Storage tab XAML (DataGrid + controls)" - path: "SharepointToolbox/Views/Tabs/StorageView.xaml.cs" provides: "StorageView code-behind" key_links: - from: "StorageViewModel.cs" to: "IStorageService.CollectStorageAsync" via: "RunOperationAsync override" pattern: "CollectStorageAsync" - from: "StorageViewModel.cs" to: "ISessionManager.GetOrCreateContextAsync" via: "context acquisition" pattern: "GetOrCreateContextAsync" - from: "StorageView.xaml" to: "StorageViewModel.Results" via: "DataGrid ItemsSource binding" pattern: "Results" --- # Plan 03-07: StorageViewModel + StorageView XAML + DI Wiring ## Goal Create the `StorageViewModel` (orchestrates `IStorageService`, export commands) and `StorageView` XAML (DataGrid with IndentLevel-based name indentation). Wire the Storage tab in `MainWindow` to replace the `FeatureTabBase` stub, register all dependencies in `App.xaml.cs`. ## Context Plans 03-02 (StorageService), 03-03 (export services), and 03-06 (localization) must complete before this plan. The ViewModel follows the exact pattern from `PermissionsViewModel`: `FeatureViewModelBase` base class, `AsyncRelayCommand` for exports, `ObservableCollection` updated via `Dispatcher.InvokeAsync` from background thread. `MainWindow.xaml` currently has the Storage tab as: ```xml ``` This plan adds `x:Name="StorageTabItem"` to that TabItem and wires `StorageTabItem.Content` in `MainWindow.xaml.cs`. The `IndentConverter` value converter maps `IndentLevel` (int) → `Thickness(IndentLevel * 16, 0, 0, 0)`. It must be defined in the View or a shared Resources file. ## Tasks ### Task 1: Create StorageViewModel **File:** `SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs` **Action:** Create **Why:** Storage tab business logic — orchestrates StorageService scan, holds results, triggers exports. ```csharp using System.Collections.ObjectModel; 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; public partial class StorageViewModel : FeatureViewModelBase { private readonly IStorageService _storageService; private readonly ISessionManager _sessionManager; private readonly StorageCsvExportService _csvExportService; private readonly StorageHtmlExportService _htmlExportService; private readonly ILogger _logger; private TenantProfile? _currentProfile; [ObservableProperty] private string _siteUrl = string.Empty; [ObservableProperty] private bool _perLibrary = true; [ObservableProperty] private bool _includeSubsites; [ObservableProperty] private int _folderDepth; public bool IsMaxDepth { get => FolderDepth >= 999; set { if (value) FolderDepth = 999; else if (FolderDepth >= 999) FolderDepth = 0; OnPropertyChanged(); } } private ObservableCollection _results = new(); public ObservableCollection Results { get => _results; private set { _results = value; OnPropertyChanged(); ExportCsvCommand.NotifyCanExecuteChanged(); ExportHtmlCommand.NotifyCanExecuteChanged(); } } public IAsyncRelayCommand ExportCsvCommand { get; } public IAsyncRelayCommand ExportHtmlCommand { get; } public TenantProfile? CurrentProfile => _currentProfile; public StorageViewModel( IStorageService storageService, ISessionManager sessionManager, StorageCsvExportService csvExportService, StorageHtmlExportService htmlExportService, ILogger logger) : base(logger) { _storageService = storageService; _sessionManager = sessionManager; _csvExportService = csvExportService; _htmlExportService = htmlExportService; _logger = logger; ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); } /// Test constructor — omits export services. internal StorageViewModel( IStorageService storageService, ISessionManager sessionManager, ILogger logger) : base(logger) { _storageService = storageService; _sessionManager = sessionManager; _csvExportService = null!; _htmlExportService = null!; _logger = logger; ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); } protected override async Task RunOperationAsync(CancellationToken ct, IProgress progress) { if (_currentProfile == null) { StatusMessage = "No tenant selected. Please connect to a tenant first."; return; } if (string.IsNullOrWhiteSpace(SiteUrl)) { StatusMessage = "Please enter a site URL."; return; } var ctx = await _sessionManager.GetOrCreateContextAsync(_currentProfile, ct); // Override URL to the site URL the user entered (may differ from tenant root) ctx.Url = SiteUrl.TrimEnd('/'); var options = new StorageScanOptions( PerLibrary: PerLibrary, IncludeSubsites: IncludeSubsites, FolderDepth: FolderDepth); var nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct); // Flatten tree to one level for DataGrid display (assign IndentLevel during flatten) var flat = new List(); foreach (var node in nodes) FlattenNode(node, 0, flat); if (Application.Current?.Dispatcher is { } dispatcher) { await dispatcher.InvokeAsync(() => { Results = new ObservableCollection(flat); }); } else { Results = new ObservableCollection(flat); } } protected override void OnTenantSwitched(TenantProfile profile) { _currentProfile = profile; Results = new ObservableCollection(); SiteUrl = string.Empty; OnPropertyChanged(nameof(CurrentProfile)); ExportCsvCommand.NotifyCanExecuteChanged(); ExportHtmlCommand.NotifyCanExecuteChanged(); } internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile; internal Task TestRunOperationAsync(CancellationToken ct, IProgress progress) => RunOperationAsync(ct, progress); private bool CanExport() => Results.Count > 0; private async Task ExportCsvAsync() { if (Results.Count == 0) return; var dialog = new SaveFileDialog { Title = "Export storage metrics to CSV", Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", DefaultExt = "csv", FileName = "storage_metrics" }; 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 (Results.Count == 0) return; var dialog = new SaveFileDialog { Title = "Export storage metrics to HTML", Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*", DefaultExt = "html", FileName = "storage_metrics" }; 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 static void FlattenNode(StorageNode node, int level, List result) { node.IndentLevel = level; result.Add(node); foreach (var child in node.Children) FlattenNode(child, level + 1, result); } private static void OpenFile(string filePath) { try { Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true }); } catch { /* ignore — file may open but this is best-effort */ } } } ``` **Verification:** ```bash dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx ``` Expected: 0 errors ### Task 2: Create StorageView XAML + code-behind, update DI and MainWindow wiring **Files:** - `SharepointToolbox/Views/Tabs/StorageView.xaml` - `SharepointToolbox/Views/Tabs/StorageView.xaml.cs` - `SharepointToolbox/Views/Converters/IndentConverter.cs` (create — also adds BytesConverter and InverseBoolConverter) - `SharepointToolbox/App.xaml` (modify — register converters as Application.Resources) - `SharepointToolbox/App.xaml.cs` (modify — add Storage registrations) - `SharepointToolbox/MainWindow.xaml` (modify — add x:Name to Storage TabItem) - `SharepointToolbox/MainWindow.xaml.cs` (modify — wire StorageTabItem.Content) **Action:** Create / Modify **Why:** STOR-01/02/03/04/05 — the UI that ties the storage service to user interaction. ```xml