All checks were successful
Release zip package / release (push) Successful in 10s
Archive 5 phases (36 plans) to milestones/v1.0-phases/. Archive roadmap, requirements, and audit to milestones/. Evolve PROJECT.md with shipped state and validated requirements. Collapse ROADMAP.md to one-line milestone summary. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
578 lines
23 KiB
Markdown
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`
|