Files
Sharepoint-Toolbox/.planning/phases/04-bulk-operations-and-provisioning/04-08-PLAN.md
Dev d73e50948d docs(04): create Phase 4 plan — 10 plans for Bulk Operations and Provisioning
Wave 0: models, interfaces, BulkOperationRunner, test scaffolds
Wave 1: CsvValidationService, TemplateRepository, FileTransferService,
        BulkMemberService, BulkSiteService, TemplateService, FolderStructureService
Wave 2: Localization, shared dialogs, example CSV resources
Wave 3: TransferVM+View, BulkMembers/BulkSites/FolderStructure VMs+Views,
        TemplatesVM+View, DI registration, MainWindow wiring

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:38:33 +02:00

19 KiB

phase, plan, title, status, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan title status wave depends_on files_modified autonomous requirements must_haves
04 08 TransferViewModel + TransferView pending 3
04-03
04-07
SharepointToolbox/ViewModels/Tabs/TransferViewModel.cs
SharepointToolbox/Views/Tabs/TransferView.xaml
SharepointToolbox/Views/Tabs/TransferView.xaml.cs
true
BULK-01
BULK-04
BULK-05
truths artifacts key_links
User can select source site via SitePickerDialog and browse source library/folder
User can select destination site and browse destination library/folder
User can choose Copy or Move mode and select conflict policy (Skip/Overwrite/Rename)
Confirmation dialog shown before transfer starts
Progress bar and cancel button work during transfer
After partial failure, user sees per-item results and can export failed items CSV
path provides exports
SharepointToolbox/ViewModels/Tabs/TransferViewModel.cs Transfer tab ViewModel
TransferViewModel
path provides
SharepointToolbox/Views/Tabs/TransferView.xaml Transfer tab XAML layout
path provides
SharepointToolbox/Views/Tabs/TransferView.xaml.cs Transfer tab code-behind
from to via pattern
TransferViewModel.cs IFileTransferService.TransferAsync RunOperationAsync override TransferAsync
from to via pattern
TransferViewModel.cs ISessionManager.GetOrCreateContextAsync context acquisition for source and dest GetOrCreateContextAsync
from to via pattern
TransferView.xaml TransferViewModel DataContext binding TransferViewModel

Plan 04-08: TransferViewModel + TransferView

Goal

Create the TransferViewModel and TransferView for the file transfer tab. Source/destination site pickers (reusing SitePickerDialog pattern), library/folder tree browser (FolderBrowserDialog), Copy/Move toggle, conflict policy selector, progress tracking, cancellation, per-item error reporting, and failed-items CSV export.

Context

IFileTransferService, TransferJob, ConflictPolicy, TransferMode from Plan 04-01. FileTransferService implemented in Plan 04-03. ConfirmBulkOperationDialog and FolderBrowserDialog from Plan 04-07. Localization keys from Plan 04-07.

ViewModel pattern: FeatureViewModelBase base class (RunCommand, CancelCommand, IsRunning, StatusMessage, ProgressValue). Override RunOperationAsync. Export commands as IAsyncRelayCommand with CanExport guard. Track _currentProfile, reset in OnTenantSwitched.

View pattern: UserControl with DockPanel. Code-behind receives ViewModel from DI, sets DataContext. Wires dialog factories.

Tasks

Task 1: Create TransferViewModel

Files:

  • SharepointToolbox/ViewModels/Tabs/TransferViewModel.cs

Action:

using System.Collections.ObjectModel;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using Serilog;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;

namespace SharepointToolbox.ViewModels.Tabs;

public partial class TransferViewModel : FeatureViewModelBase
{
    private readonly IFileTransferService _transferService;
    private readonly ISessionManager _sessionManager;
    private readonly BulkResultCsvExportService _exportService;
    private readonly ILogger<FeatureViewModelBase> _logger;
    private TenantProfile? _currentProfile;

    // Source selection
    [ObservableProperty] private string _sourceSiteUrl = string.Empty;
    [ObservableProperty] private string _sourceLibrary = string.Empty;
    [ObservableProperty] private string _sourceFolderPath = string.Empty;

    // Destination selection
    [ObservableProperty] private string _destSiteUrl = string.Empty;
    [ObservableProperty] private string _destLibrary = string.Empty;
    [ObservableProperty] private string _destFolderPath = string.Empty;

    // Transfer options
    [ObservableProperty] private TransferMode _transferMode = TransferMode.Copy;
    [ObservableProperty] private ConflictPolicy _conflictPolicy = ConflictPolicy.Skip;

    // Results
    [ObservableProperty] private string _resultSummary = string.Empty;
    [ObservableProperty] private bool _hasFailures;

    private BulkOperationSummary<string>? _lastResult;

    public IAsyncRelayCommand ExportFailedCommand { get; }

    // Dialog factories — set by View code-behind
    public Func<TenantProfile, Window>? OpenSitePickerDialog { get; set; }
    public Func<Microsoft.SharePoint.Client.ClientContext, Views.Dialogs.FolderBrowserDialog>? OpenFolderBrowserDialog { get; set; }
    public Func<string, bool>? ShowConfirmDialog { get; set; }

    public TransferViewModel(
        IFileTransferService transferService,
        ISessionManager sessionManager,
        BulkResultCsvExportService exportService,
        ILogger<FeatureViewModelBase> logger)
        : base(logger)
    {
        _transferService = transferService;
        _sessionManager = sessionManager;
        _exportService = exportService;
        _logger = logger;

        ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures);
    }

    protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
    {
        if (_currentProfile == null)
            throw new InvalidOperationException("No tenant connected.");

        if (string.IsNullOrWhiteSpace(SourceSiteUrl) || string.IsNullOrWhiteSpace(SourceLibrary))
            throw new InvalidOperationException("Source site and library must be selected.");

        if (string.IsNullOrWhiteSpace(DestSiteUrl) || string.IsNullOrWhiteSpace(DestLibrary))
            throw new InvalidOperationException("Destination site and library must be selected.");

        // Confirmation dialog
        var message = $"{TransferMode} files from {SourceLibrary} to {DestLibrary} ({ConflictPolicy} on conflict)";
        if (ShowConfirmDialog != null && !ShowConfirmDialog(message))
            return;

        var job = new TransferJob
        {
            SourceSiteUrl = SourceSiteUrl,
            SourceLibrary = SourceLibrary,
            SourceFolderPath = SourceFolderPath,
            DestinationSiteUrl = DestSiteUrl,
            DestinationLibrary = DestLibrary,
            DestinationFolderPath = DestFolderPath,
            Mode = TransferMode,
            ConflictPolicy = ConflictPolicy,
        };

        // Get contexts for source and destination
        var srcProfile = new TenantProfile
        {
            Name = _currentProfile.Name,
            TenantUrl = SourceSiteUrl,
            ClientId = _currentProfile.ClientId,
        };
        var dstProfile = new TenantProfile
        {
            Name = _currentProfile.Name,
            TenantUrl = DestSiteUrl,
            ClientId = _currentProfile.ClientId,
        };

        var srcCtx = await _sessionManager.GetOrCreateContextAsync(srcProfile, ct);
        var dstCtx = await _sessionManager.GetOrCreateContextAsync(dstProfile, ct);

        _lastResult = await _transferService.TransferAsync(srcCtx, dstCtx, job, progress, ct);

        // Update UI on dispatcher
        await Application.Current.Dispatcher.InvokeAsync(() =>
        {
            HasFailures = _lastResult.HasFailures;
            ExportFailedCommand.NotifyCanExecuteChanged();

            if (_lastResult.HasFailures)
            {
                ResultSummary = string.Format(
                    Localization.TranslationSource.Instance["bulk.result.success"],
                    _lastResult.SuccessCount, _lastResult.FailedCount);
            }
            else
            {
                ResultSummary = string.Format(
                    Localization.TranslationSource.Instance["bulk.result.allsuccess"],
                    _lastResult.TotalCount);
            }
        });
    }

    private async Task ExportFailedAsync()
    {
        if (_lastResult == null || !_lastResult.HasFailures) return;

        var dlg = new SaveFileDialog
        {
            Filter = "CSV Files (*.csv)|*.csv",
            FileName = "transfer_failed_items.csv",
        };

        if (dlg.ShowDialog() == true)
        {
            await _exportService.WriteFailedItemsCsvAsync(
                _lastResult.FailedItems.ToList(),
                dlg.FileName,
                CancellationToken.None);
            Log.Information("Exported failed transfer items to {Path}", dlg.FileName);
        }
    }

    protected override void OnTenantSwitched(TenantProfile profile)
    {
        _currentProfile = profile;
        SourceSiteUrl = string.Empty;
        SourceLibrary = string.Empty;
        SourceFolderPath = string.Empty;
        DestSiteUrl = string.Empty;
        DestLibrary = string.Empty;
        DestFolderPath = string.Empty;
        ResultSummary = string.Empty;
        HasFailures = false;
        _lastResult = null;
    }
}

Verify:

dotnet build SharepointToolbox/SharepointToolbox.csproj --no-restore -q

Done: TransferViewModel compiles with source/dest selection, transfer mode, conflict policy, confirmation dialog, per-item results, and failed-items export.

Task 2: Create TransferView XAML + code-behind

Files:

  • SharepointToolbox/Views/Tabs/TransferView.xaml
  • SharepointToolbox/Views/Tabs/TransferView.xaml.cs

Action:

Create TransferView.xaml:

<UserControl x:Class="SharepointToolbox.Views.Tabs.TransferView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:loc="clr-namespace:SharepointToolbox.Localization">
    <DockPanel Margin="10">
        <!-- Options Panel (Left) -->
        <StackPanel DockPanel.Dock="Left" Width="340" Margin="0,0,10,0">
            <!-- Source -->
            <GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.sourcesite]}"
                      Margin="0,0,0,10">
                <StackPanel Margin="5">
                    <TextBox Text="{Binding SourceSiteUrl, UpdateSourceTrigger=PropertyChanged}"
                             IsReadOnly="True" Margin="0,0,0,5" />
                    <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.browse]}"
                            Click="BrowseSource_Click" Margin="0,0,0,5" />
                    <TextBlock Text="{Binding SourceLibrary}" FontWeight="SemiBold" />
                    <TextBlock Text="{Binding SourceFolderPath}" Foreground="Gray" />
                </StackPanel>
            </GroupBox>

            <!-- Destination -->
            <GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.destsite]}"
                      Margin="0,0,0,10">
                <StackPanel Margin="5">
                    <TextBox Text="{Binding DestSiteUrl, UpdateSourceTrigger=PropertyChanged}"
                             IsReadOnly="True" Margin="0,0,0,5" />
                    <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.browse]}"
                            Click="BrowseDest_Click" Margin="0,0,0,5" />
                    <TextBlock Text="{Binding DestLibrary}" FontWeight="SemiBold" />
                    <TextBlock Text="{Binding DestFolderPath}" Foreground="Gray" />
                </StackPanel>
            </GroupBox>

            <!-- Transfer Mode -->
            <GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.mode]}"
                      Margin="0,0,0,10">
                <StackPanel Margin="5">
                    <RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.mode.copy]}"
                                 IsChecked="{Binding TransferMode, Converter={StaticResource EnumBoolConverter}, ConverterParameter=Copy}"
                                 Margin="0,0,0,3" />
                    <RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.mode.move]}"
                                 IsChecked="{Binding TransferMode, Converter={StaticResource EnumBoolConverter}, ConverterParameter=Move}" />
                </StackPanel>
            </GroupBox>

            <!-- Conflict Policy -->
            <GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict]}"
                      Margin="0,0,0,10">
                <ComboBox SelectedIndex="0" x:Name="ConflictCombo" SelectionChanged="ConflictCombo_SelectionChanged">
                    <ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict.skip]}" />
                    <ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict.overwrite]}" />
                    <ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.conflict.rename]}" />
                </ComboBox>
            </GroupBox>

            <!-- Actions -->
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.start]}"
                    Command="{Binding RunCommand}" Margin="0,0,0,5"
                    IsEnabled="{Binding IsRunning, Converter={StaticResource InverseBoolConverter}}" />
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[status.cancelled]}"
                    Command="{Binding CancelCommand}"
                    Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />

            <!-- Progress -->
            <ProgressBar Height="20" Margin="0,10,0,5"
                         Value="{Binding ProgressValue}"
                         Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
            <TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />

            <!-- Results -->
            <TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5"
                       Visibility="{Binding ResultSummary, Converter={StaticResource StringToVisibilityConverter}}" />
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}"
                    Command="{Binding ExportFailedCommand}"
                    Visibility="{Binding HasFailures, Converter={StaticResource BoolToVisibilityConverter}}" />
        </StackPanel>

        <!-- Right panel placeholder for future enhancements -->
        <Border />
    </DockPanel>
</UserControl>

Note: The XAML uses converters (InverseBoolConverter, BoolToVisibilityConverter, StringToVisibilityConverter, EnumBoolConverter). If EnumBoolConverter or StringToVisibilityConverter don't already exist in the project, create them in Views/Converters/ directory. The InverseBoolConverter and BoolToVisibilityConverter should already exist from Phase 1. If BoolToVisibilityConverter is not registered, use standard WPF BooleanToVisibilityConverter. If converters are not available, simplify the XAML to use code-behind visibility toggling instead.

Create TransferView.xaml.cs:

using System.Windows;
using System.Windows.Controls;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.Views.Dialogs;

namespace SharepointToolbox.Views.Tabs;

public partial class TransferView : UserControl
{
    private readonly ViewModels.Tabs.TransferViewModel _viewModel;
    private readonly ISessionManager _sessionManager;
    private readonly Func<TenantProfile, SitePickerDialog> _sitePickerFactory;

    public TransferView(
        ViewModels.Tabs.TransferViewModel viewModel,
        ISessionManager sessionManager,
        Func<TenantProfile, SitePickerDialog> sitePickerFactory)
    {
        InitializeComponent();
        _viewModel = viewModel;
        _sessionManager = sessionManager;
        _sitePickerFactory = sitePickerFactory;
        DataContext = viewModel;

        viewModel.ShowConfirmDialog = message =>
        {
            var dlg = new ConfirmBulkOperationDialog(message) { Owner = Window.GetWindow(this) };
            dlg.ShowDialog();
            return dlg.IsConfirmed;
        };
    }

    private async void BrowseSource_Click(object sender, RoutedEventArgs e)
    {
        if (_viewModel.CurrentProfile == null) return;

        // Pick site
        var sitePicker = _sitePickerFactory(_viewModel.CurrentProfile);
        sitePicker.Owner = Window.GetWindow(this);
        if (sitePicker.ShowDialog() != true || sitePicker.SelectedSite == null) return;

        _viewModel.SourceSiteUrl = sitePicker.SelectedSite.Url;

        // Browse library/folder
        var profile = new TenantProfile
        {
            Name = _viewModel.CurrentProfile.Name,
            TenantUrl = sitePicker.SelectedSite.Url,
            ClientId = _viewModel.CurrentProfile.ClientId,
        };
        var ctx = await _sessionManager.GetOrCreateContextAsync(profile, CancellationToken.None);
        var folderBrowser = new FolderBrowserDialog(ctx) { Owner = Window.GetWindow(this) };
        if (folderBrowser.ShowDialog() == true)
        {
            _viewModel.SourceLibrary = folderBrowser.SelectedLibrary;
            _viewModel.SourceFolderPath = folderBrowser.SelectedFolderPath;
        }
    }

    private async void BrowseDest_Click(object sender, RoutedEventArgs e)
    {
        if (_viewModel.CurrentProfile == null) return;

        var sitePicker = _sitePickerFactory(_viewModel.CurrentProfile);
        sitePicker.Owner = Window.GetWindow(this);
        if (sitePicker.ShowDialog() != true || sitePicker.SelectedSite == null) return;

        _viewModel.DestSiteUrl = sitePicker.SelectedSite.Url;

        var profile = new TenantProfile
        {
            Name = _viewModel.CurrentProfile.Name,
            TenantUrl = sitePicker.SelectedSite.Url,
            ClientId = _viewModel.CurrentProfile.ClientId,
        };
        var ctx = await _sessionManager.GetOrCreateContextAsync(profile, CancellationToken.None);
        var folderBrowser = new FolderBrowserDialog(ctx) { Owner = Window.GetWindow(this) };
        if (folderBrowser.ShowDialog() == true)
        {
            _viewModel.DestLibrary = folderBrowser.SelectedLibrary;
            _viewModel.DestFolderPath = folderBrowser.SelectedFolderPath;
        }
    }

    private void ConflictCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        if (ConflictCombo.SelectedIndex >= 0)
        {
            _viewModel.ConflictPolicy = (ConflictPolicy)ConflictCombo.SelectedIndex;
        }
    }

    // Expose CurrentProfile for site picker dialog
    private TenantProfile? CurrentProfile => _viewModel.CurrentProfile;
}

Note on CurrentProfile: The TransferViewModel needs to expose _currentProfile publicly (add public TenantProfile? CurrentProfile => _currentProfile; property to TransferViewModel, similar to StorageViewModel pattern).

Verify:

dotnet build SharepointToolbox/SharepointToolbox.csproj --no-restore -q

Done: TransferView compiles. Source/dest site pickers via SitePickerDialog, library/folder browsing via FolderBrowserDialog, Copy/Move radio buttons, conflict policy dropdown, confirmation dialog before start, progress tracking, failed-items export.

Commit: feat(04-08): create TransferViewModel and TransferView