Files
Sharepoint-Toolbox/.planning/phases/03-storage/03-07-PLAN.md
Dev 43dd6ce17f docs(03-08): complete SearchViewModel + DuplicatesViewModel + Views plan — Phase 3 complete
- 3 tasks completed, 9 files created/modified
- Visual checkpoint pending: all three Phase 3 tabs wired and ready for UI verification
2026-04-02 16:10:54 +02:00

578 lines
23 KiB
Markdown

---
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<T> 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
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.storage]}">
<controls:FeatureTabBase />
</TabItem>
```
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<FeatureViewModelBase> _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<StorageNode> _results = new();
public ObservableCollection<StorageNode> 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<FeatureViewModelBase> logger)
: base(logger)
{
_storageService = storageService;
_sessionManager = sessionManager;
_csvExportService = csvExportService;
_htmlExportService = htmlExportService;
_logger = logger;
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
}
/// <summary>Test constructor — omits export services.</summary>
internal StorageViewModel(
IStorageService storageService,
ISessionManager sessionManager,
ILogger<FeatureViewModelBase> 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<OperationProgress> 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<StorageNode>();
foreach (var node in nodes)
FlattenNode(node, 0, flat);
if (Application.Current?.Dispatcher is { } dispatcher)
{
await dispatcher.InvokeAsync(() =>
{
Results = new ObservableCollection<StorageNode>(flat);
});
}
else
{
Results = new ObservableCollection<StorageNode>(flat);
}
}
protected override void OnTenantSwitched(TenantProfile profile)
{
_currentProfile = profile;
Results = new ObservableCollection<StorageNode>();
SiteUrl = string.Empty;
OnPropertyChanged(nameof(CurrentProfile));
ExportCsvCommand.NotifyCanExecuteChanged();
ExportHtmlCommand.NotifyCanExecuteChanged();
}
internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
internal Task TestRunOperationAsync(CancellationToken ct, IProgress<OperationProgress> 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<StorageNode> 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
<!-- SharepointToolbox/Views/Tabs/StorageView.xaml -->
<UserControl x:Class="SharepointToolbox.Views.Tabs.StorageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
xmlns:conv="clr-namespace:SharepointToolbox.Views.Converters">
<UserControl.Resources>
<conv:IndentConverter x:Key="IndentConverter" />
</UserControl.Resources>
<DockPanel LastChildFill="True">
<!-- Options panel -->
<ScrollViewer DockPanel.Dock="Left" Width="240" VerticalScrollBarVisibility="Auto"
Margin="8,8,4,8">
<StackPanel>
<!-- Site URL -->
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.site.url]}" />
<TextBox Text="{Binding SiteUrl, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding IsRunning, Converter={StaticResource InverseBoolConverter}}"
Height="26" Margin="0,0,0,8"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.site.url]}" />
<!-- Scan options group -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.scan.opts]}"
Margin="0,0,0,8">
<StackPanel Margin="4">
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.per.lib]}"
IsChecked="{Binding PerLibrary}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.subsites]}"
IsChecked="{Binding IncludeSubsites}" Margin="0,2" />
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.folder.depth]}"
VerticalAlignment="Center" Padding="0,0,4,0" />
<TextBox Text="{Binding FolderDepth, UpdateSourceTrigger=PropertyChanged}"
Width="40" Height="22" VerticalAlignment="Center"
IsEnabled="{Binding IsMaxDepth, Converter={StaticResource InverseBoolConverter}}" />
</StackPanel>
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.max.depth]}"
IsChecked="{Binding IsMaxDepth}" Margin="0,2" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.note]}"
TextWrapping="Wrap" FontSize="11" Foreground="#888"
Margin="0,6,0,0" />
</StackPanel>
</GroupBox>
<!-- Action buttons -->
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.gen.storage]}"
Command="{Binding RunCommand}"
Height="28" Margin="0,0,0,4" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
Command="{Binding CancelCommand}"
Height="28" Margin="0,0,0,8" />
<!-- Export group -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.export.fmt]}"
Margin="0,0,0,8">
<StackPanel Margin="4">
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.rad.csv]}"
Command="{Binding ExportCsvCommand}"
Height="26" Margin="0,2" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.rad.html]}"
Command="{Binding ExportHtmlCommand}"
Height="26" Margin="0,2" />
</StackPanel>
</GroupBox>
<!-- Status -->
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap"
FontSize="11" Foreground="#555" Margin="0,4" />
</StackPanel>
</ScrollViewer>
<!-- Results DataGrid -->
<DataGrid x:Name="ResultsGrid"
ItemsSource="{Binding Results}"
IsReadOnly="True"
AutoGenerateColumns="False"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
Margin="4,8,8,8">
<DataGrid.Columns>
<DataGridTemplateColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.library]}"
Width="*" MinWidth="160">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}"
Margin="{Binding IndentLevel, Converter={StaticResource IndentConverter}}"
VerticalAlignment="Center" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.site]}"
Binding="{Binding SiteTitle}" Width="140" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.files]}"
Binding="{Binding TotalFileCount, StringFormat=N0}"
Width="70" ElementStyle="{StaticResource RightAlignStyle}" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.size]}"
Binding="{Binding TotalSizeBytes, Converter={StaticResource BytesConverter}}"
Width="100" ElementStyle="{StaticResource RightAlignStyle}" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.versions]}"
Binding="{Binding VersionSizeBytes, Converter={StaticResource BytesConverter}}"
Width="110" ElementStyle="{StaticResource RightAlignStyle}" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.lastmod]}"
Binding="{Binding LastModified, StringFormat=yyyy-MM-dd}"
Width="110" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</UserControl>
```
```csharp
// SharepointToolbox/Views/Tabs/StorageView.xaml.cs
using System.Windows.Controls;
namespace SharepointToolbox.Views.Tabs;
public partial class StorageView : UserControl
{
public StorageView(ViewModels.Tabs.StorageViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
}
```
The XAML references three resource converters. Create all three in a single file:
```csharp
// SharepointToolbox/Views/Converters/IndentConverter.cs
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace SharepointToolbox.Views.Converters;
/// <summary>Converts IndentLevel (int) to WPF Thickness for DataGrid indent.</summary>
[ValueConversion(typeof(int), typeof(Thickness))]
public class IndentConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
int level = value is int i ? i : 0;
return new Thickness(level * 16, 0, 0, 0);
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotImplementedException();
}
/// <summary>Converts byte count (long) to human-readable size string.</summary>
[ValueConversion(typeof(long), typeof(string))]
public class BytesConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
long bytes = value is long l ? l : 0L;
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
if (bytes >= 1_048_576L) return $"{bytes / 1_048_576.0:F2} MB";
if (bytes >= 1024L) return $"{bytes / 1024.0:F2} KB";
return $"{bytes} B";
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotImplementedException();
}
/// <summary>Inverts a bool binding — used to disable controls while an operation is running.</summary>
[ValueConversion(typeof(bool), typeof(bool))]
public class InverseBoolConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> value is bool b && !b;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> value is bool b && !b;
}
```
Register converters and styles in `App.xaml` `<Application.Resources>`. Check `App.xaml` first — if `InverseBoolConverter` was already added by a previous plan, do not duplicate it. Add whichever of these are missing:
```xml
<conv:IndentConverter x:Key="IndentConverter" />
<conv:BytesConverter x:Key="BytesConverter" />
<conv:InverseBoolConverter x:Key="InverseBoolConverter" />
<Style x:Key="RightAlignStyle" TargetType="TextBlock">
<Setter Property="HorizontalAlignment" Value="Right" />
</Style>
```
Also ensure the `conv` xmlns is declared on the `Application` root element if not already present:
```xml
xmlns:conv="clr-namespace:SharepointToolbox.Views.Converters"
```
In `App.xaml.cs` `ConfigureServices`, add before existing Phase 2 registrations:
```csharp
// Phase 3: Storage
services.AddTransient<IStorageService, StorageService>();
services.AddTransient<StorageCsvExportService>();
services.AddTransient<StorageHtmlExportService>();
services.AddTransient<StorageViewModel>();
services.AddTransient<StorageView>();
```
In `MainWindow.xaml`, change the Storage TabItem from:
```xml
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.storage]}">
<controls:FeatureTabBase />
</TabItem>
```
to:
```xml
<TabItem x:Name="StorageTabItem"
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.storage]}">
</TabItem>
```
In `MainWindow.xaml.cs`, add after the PermissionsTabItem wiring line:
```csharp
StorageTabItem.Content = serviceProvider.GetRequiredService<StorageView>();
```
**Verification:**
```bash
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj -x 2>&1 | tail -5
```
Expected: 0 build errors; all tests pass
## Verification
```bash
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
```
Expected: 0 errors. StorageView wired in MainWindow (grep: `StorageTabItem.Content`). StorageService registered in DI (grep: `IStorageService, StorageService`). `InverseBoolConverter` registered in App.xaml resources (grep: `InverseBoolConverter`).
## Commit Message
feat(03-07): create StorageViewModel, StorageView XAML, DI registration, and MainWindow wiring
## Output
After completion, create `.planning/phases/03-storage/03-07-SUMMARY.md`