Files
Sharepoint-Toolbox/.planning/milestones/v1.0-phases/04-bulk-operations-and-provisioning/04-09-PLAN.md
Dev 655bb79a99
All checks were successful
Release zip package / release (push) Successful in 10s
chore: complete v1.0 milestone
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>
2026-04-07 09:15:14 +02:00

38 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 09 BulkMembersViewModel + BulkSitesViewModel + FolderStructureViewModel + Views pending 3
04-02
04-04
04-05
04-06
04-07
SharepointToolbox/ViewModels/Tabs/BulkMembersViewModel.cs
SharepointToolbox/ViewModels/Tabs/BulkSitesViewModel.cs
SharepointToolbox/ViewModels/Tabs/FolderStructureViewModel.cs
SharepointToolbox/Views/Tabs/BulkMembersView.xaml
SharepointToolbox/Views/Tabs/BulkMembersView.xaml.cs
SharepointToolbox/Views/Tabs/BulkSitesView.xaml
SharepointToolbox/Views/Tabs/BulkSitesView.xaml.cs
SharepointToolbox/Views/Tabs/FolderStructureView.xaml
SharepointToolbox/Views/Tabs/FolderStructureView.xaml.cs
true
BULK-02
BULK-03
BULK-04
BULK-05
FOLD-01
FOLD-02
truths artifacts key_links
All three CSV tabs follow the same flow: Import CSV -> Validate -> Preview DataGrid -> Confirm -> Execute
Each DataGrid preview shows valid/invalid row indicators
Invalid rows highlighted — user can fix and re-import before executing
Confirmation dialog shown before execution
Retry Failed button appears after partial failures
Failed-items CSV export available after any failure
Load Example button loads bundled CSV from embedded resources
path provides exports
SharepointToolbox/ViewModels/Tabs/BulkMembersViewModel.cs Bulk Members tab ViewModel
BulkMembersViewModel
path provides exports
SharepointToolbox/ViewModels/Tabs/BulkSitesViewModel.cs Bulk Sites tab ViewModel
BulkSitesViewModel
path provides exports
SharepointToolbox/ViewModels/Tabs/FolderStructureViewModel.cs Folder Structure tab ViewModel
FolderStructureViewModel
path provides
SharepointToolbox/Views/Tabs/BulkMembersView.xaml Bulk Members tab UI
path provides
SharepointToolbox/Views/Tabs/BulkSitesView.xaml Bulk Sites tab UI
path provides
SharepointToolbox/Views/Tabs/FolderStructureView.xaml Folder Structure tab UI
from to via pattern
BulkMembersViewModel.cs IBulkMemberService.AddMembersAsync RunOperationAsync override AddMembersAsync
from to via pattern
BulkSitesViewModel.cs IBulkSiteService.CreateSitesAsync RunOperationAsync override CreateSitesAsync
from to via pattern
FolderStructureViewModel.cs IFolderStructureService.CreateFoldersAsync RunOperationAsync override CreateFoldersAsync

Plan 04-09: BulkMembersViewModel + BulkSitesViewModel + FolderStructureViewModel + Views

Goal

Create all three CSV-based bulk operation tabs (Bulk Members, Bulk Sites, Folder Structure). Each follows the same flow: Import CSV -> Validate via CsvValidationService -> Preview in DataGrid -> Confirm via ConfirmBulkOperationDialog -> Execute via respective service -> Report results. Includes Retry Failed button and failed-items CSV export.

Context

Services: IBulkMemberService (04-04), IBulkSiteService (04-05), IFolderStructureService (04-06), ICsvValidationService (04-02). Shared UI: ConfirmBulkOperationDialog (04-07), BulkResultCsvExportService (04-01). Localization keys from Plan 04-07.

All three ViewModels follow FeatureViewModelBase pattern. The CSV import flow is identical across all three — only the row model, validation, and service call differ.

Example CSVs are embedded resources accessed via Assembly.GetExecutingAssembly().GetManifestResourceStream().

Tasks

Task 1: Create BulkMembersViewModel + BulkSitesViewModel + FolderStructureViewModel

Files:

  • SharepointToolbox/ViewModels/Tabs/BulkMembersViewModel.cs
  • SharepointToolbox/ViewModels/Tabs/BulkSitesViewModel.cs
  • SharepointToolbox/ViewModels/Tabs/FolderStructureViewModel.cs

Action:

  1. Create BulkMembersViewModel.cs:
using System.Collections.ObjectModel;
using System.IO;
using System.Reflection;
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.Localization;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;

namespace SharepointToolbox.ViewModels.Tabs;

public partial class BulkMembersViewModel : FeatureViewModelBase
{
    private readonly IBulkMemberService _memberService;
    private readonly ICsvValidationService _csvService;
    private readonly ISessionManager _sessionManager;
    private readonly BulkResultCsvExportService _exportService;
    private readonly ILogger<FeatureViewModelBase> _logger;
    private TenantProfile? _currentProfile;
    private List<BulkMemberRow>? _validRows;
    private List<BulkMemberRow>? _failedRowsForRetry;
    private BulkOperationSummary<BulkMemberRow>? _lastResult;

    [ObservableProperty] private string _previewSummary = string.Empty;
    [ObservableProperty] private string _resultSummary = string.Empty;
    [ObservableProperty] private bool _hasFailures;
    [ObservableProperty] private bool _hasPreview;

    private ObservableCollection<CsvValidationRow<BulkMemberRow>> _previewRows = new();
    public ObservableCollection<CsvValidationRow<BulkMemberRow>> PreviewRows
    {
        get => _previewRows;
        private set { _previewRows = value; OnPropertyChanged(); }
    }

    public IRelayCommand ImportCsvCommand { get; }
    public IRelayCommand LoadExampleCommand { get; }
    public IAsyncRelayCommand ExportFailedCommand { get; }
    public IAsyncRelayCommand RetryFailedCommand { get; }

    public Func<string, bool>? ShowConfirmDialog { get; set; }
    public TenantProfile? CurrentProfile => _currentProfile;

    public BulkMembersViewModel(
        IBulkMemberService memberService,
        ICsvValidationService csvService,
        ISessionManager sessionManager,
        BulkResultCsvExportService exportService,
        ILogger<FeatureViewModelBase> logger)
        : base(logger)
    {
        _memberService = memberService;
        _csvService = csvService;
        _sessionManager = sessionManager;
        _exportService = exportService;
        _logger = logger;

        ImportCsvCommand = new RelayCommand(ImportCsv);
        LoadExampleCommand = new RelayCommand(LoadExample);
        ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures);
        RetryFailedCommand = new AsyncRelayCommand(RetryFailedAsync, () => HasFailures);
    }

    private void ImportCsv()
    {
        var dlg = new OpenFileDialog
        {
            Title = TranslationSource.Instance["bulk.csvimport.title"],
            Filter = TranslationSource.Instance["bulk.csvimport.filter"],
        };
        if (dlg.ShowDialog() != true) return;

        using var stream = File.OpenRead(dlg.FileName);
        LoadAndPreview(stream);
    }

    private void LoadExample()
    {
        var assembly = Assembly.GetExecutingAssembly();
        var resourceName = assembly.GetManifestResourceNames()
            .FirstOrDefault(n => n.EndsWith("bulk_add_members.csv", StringComparison.OrdinalIgnoreCase));
        if (resourceName == null) return;

        using var stream = assembly.GetManifestResourceStream(resourceName);
        if (stream != null) LoadAndPreview(stream);
    }

    private void LoadAndPreview(Stream stream)
    {
        var rows = _csvService.ParseAndValidateMembers(stream);
        PreviewRows = new ObservableCollection<CsvValidationRow<BulkMemberRow>>(rows);
        _validRows = rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList();
        var invalidCount = rows.Count - _validRows.Count;
        PreviewSummary = string.Format(TranslationSource.Instance["bulkmembers.preview"],
            rows.Count, _validRows.Count, invalidCount);
        HasPreview = true;
        ResultSummary = string.Empty;
        HasFailures = false;
    }

    protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
    {
        if (_currentProfile == null) throw new InvalidOperationException("No tenant connected.");
        if (_validRows == null || _validRows.Count == 0)
            throw new InvalidOperationException("No valid rows to process. Import a CSV first.");

        var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
            $"{_validRows.Count} members will be added");
        if (ShowConfirmDialog != null && !ShowConfirmDialog(message))
            return;

        var ctx = await _sessionManager.GetOrCreateContextAsync(_currentProfile, ct);
        _lastResult = await _memberService.AddMembersAsync(ctx, _validRows, progress, ct);

        await Application.Current.Dispatcher.InvokeAsync(() =>
        {
            HasFailures = _lastResult.HasFailures;
            _failedRowsForRetry = _lastResult.HasFailures
                ? _lastResult.FailedItems.Select(r => r.Item).ToList()
                : null;
            ExportFailedCommand.NotifyCanExecuteChanged();
            RetryFailedCommand.NotifyCanExecuteChanged();

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

    private async Task RetryFailedAsync()
    {
        if (_failedRowsForRetry == null || _failedRowsForRetry.Count == 0) return;
        _validRows = _failedRowsForRetry;
        HasFailures = false;
        await RunCommand.ExecuteAsync(null);
    }

    private async Task ExportFailedAsync()
    {
        if (_lastResult == null || !_lastResult.HasFailures) return;
        var dlg = new SaveFileDialog { Filter = "CSV Files (*.csv)|*.csv", FileName = "failed_members.csv" };
        if (dlg.ShowDialog() == true)
        {
            await _exportService.WriteFailedItemsCsvAsync(_lastResult.FailedItems.ToList(), dlg.FileName, CancellationToken.None);
            Log.Information("Exported failed member rows to {Path}", dlg.FileName);
        }
    }

    protected override void OnTenantSwitched(TenantProfile profile)
    {
        _currentProfile = profile;
        PreviewRows = new();
        _validRows = null;
        PreviewSummary = string.Empty;
        ResultSummary = string.Empty;
        HasFailures = false;
        HasPreview = false;
    }
}
  1. Create BulkSitesViewModel.cs — follows same pattern as BulkMembersViewModel but uses IBulkSiteService and BulkSiteRow:
using System.Collections.ObjectModel;
using System.IO;
using System.Reflection;
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.Localization;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;

namespace SharepointToolbox.ViewModels.Tabs;

public partial class BulkSitesViewModel : FeatureViewModelBase
{
    private readonly IBulkSiteService _siteService;
    private readonly ICsvValidationService _csvService;
    private readonly ISessionManager _sessionManager;
    private readonly BulkResultCsvExportService _exportService;
    private readonly ILogger<FeatureViewModelBase> _logger;
    private TenantProfile? _currentProfile;
    private List<BulkSiteRow>? _validRows;
    private List<BulkSiteRow>? _failedRowsForRetry;
    private BulkOperationSummary<BulkSiteRow>? _lastResult;

    [ObservableProperty] private string _previewSummary = string.Empty;
    [ObservableProperty] private string _resultSummary = string.Empty;
    [ObservableProperty] private bool _hasFailures;
    [ObservableProperty] private bool _hasPreview;

    private ObservableCollection<CsvValidationRow<BulkSiteRow>> _previewRows = new();
    public ObservableCollection<CsvValidationRow<BulkSiteRow>> PreviewRows
    {
        get => _previewRows;
        private set { _previewRows = value; OnPropertyChanged(); }
    }

    public IRelayCommand ImportCsvCommand { get; }
    public IRelayCommand LoadExampleCommand { get; }
    public IAsyncRelayCommand ExportFailedCommand { get; }
    public IAsyncRelayCommand RetryFailedCommand { get; }

    public Func<string, bool>? ShowConfirmDialog { get; set; }
    public TenantProfile? CurrentProfile => _currentProfile;

    public BulkSitesViewModel(
        IBulkSiteService siteService,
        ICsvValidationService csvService,
        ISessionManager sessionManager,
        BulkResultCsvExportService exportService,
        ILogger<FeatureViewModelBase> logger)
        : base(logger)
    {
        _siteService = siteService;
        _csvService = csvService;
        _sessionManager = sessionManager;
        _exportService = exportService;
        _logger = logger;

        ImportCsvCommand = new RelayCommand(ImportCsv);
        LoadExampleCommand = new RelayCommand(LoadExample);
        ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures);
        RetryFailedCommand = new AsyncRelayCommand(RetryFailedAsync, () => HasFailures);
    }

    private void ImportCsv()
    {
        var dlg = new OpenFileDialog
        {
            Title = TranslationSource.Instance["bulk.csvimport.title"],
            Filter = TranslationSource.Instance["bulk.csvimport.filter"],
        };
        if (dlg.ShowDialog() != true) return;

        using var stream = File.OpenRead(dlg.FileName);
        LoadAndPreview(stream);
    }

    private void LoadExample()
    {
        var assembly = Assembly.GetExecutingAssembly();
        var resourceName = assembly.GetManifestResourceNames()
            .FirstOrDefault(n => n.EndsWith("bulk_create_sites.csv", StringComparison.OrdinalIgnoreCase));
        if (resourceName == null) return;

        using var stream = assembly.GetManifestResourceStream(resourceName);
        if (stream != null) LoadAndPreview(stream);
    }

    private void LoadAndPreview(Stream stream)
    {
        var rows = _csvService.ParseAndValidateSites(stream);
        PreviewRows = new ObservableCollection<CsvValidationRow<BulkSiteRow>>(rows);
        _validRows = rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList();
        var invalidCount = rows.Count - _validRows.Count;
        PreviewSummary = string.Format(TranslationSource.Instance["bulksites.preview"],
            rows.Count, _validRows.Count, invalidCount);
        HasPreview = true;
        ResultSummary = string.Empty;
        HasFailures = false;
    }

    protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
    {
        if (_currentProfile == null) throw new InvalidOperationException("No tenant connected.");
        if (_validRows == null || _validRows.Count == 0)
            throw new InvalidOperationException("No valid rows to process. Import a CSV first.");

        var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
            $"{_validRows.Count} sites will be created");
        if (ShowConfirmDialog != null && !ShowConfirmDialog(message))
            return;

        var ctx = await _sessionManager.GetOrCreateContextAsync(_currentProfile, ct);
        _lastResult = await _siteService.CreateSitesAsync(ctx, _validRows, progress, ct);

        await Application.Current.Dispatcher.InvokeAsync(() =>
        {
            HasFailures = _lastResult.HasFailures;
            _failedRowsForRetry = _lastResult.HasFailures
                ? _lastResult.FailedItems.Select(r => r.Item).ToList()
                : null;
            ExportFailedCommand.NotifyCanExecuteChanged();
            RetryFailedCommand.NotifyCanExecuteChanged();

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

    private async Task RetryFailedAsync()
    {
        if (_failedRowsForRetry == null || _failedRowsForRetry.Count == 0) return;
        _validRows = _failedRowsForRetry;
        HasFailures = false;
        await RunCommand.ExecuteAsync(null);
    }

    private async Task ExportFailedAsync()
    {
        if (_lastResult == null || !_lastResult.HasFailures) return;
        var dlg = new SaveFileDialog { Filter = "CSV Files (*.csv)|*.csv", FileName = "failed_sites.csv" };
        if (dlg.ShowDialog() == true)
        {
            await _exportService.WriteFailedItemsCsvAsync(_lastResult.FailedItems.ToList(), dlg.FileName, CancellationToken.None);
        }
    }

    protected override void OnTenantSwitched(TenantProfile profile)
    {
        _currentProfile = profile;
        PreviewRows = new();
        _validRows = null;
        PreviewSummary = string.Empty;
        ResultSummary = string.Empty;
        HasFailures = false;
        HasPreview = false;
    }
}
  1. Create FolderStructureViewModel.cs:
using System.Collections.ObjectModel;
using System.IO;
using System.Reflection;
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.Localization;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;

namespace SharepointToolbox.ViewModels.Tabs;

public partial class FolderStructureViewModel : FeatureViewModelBase
{
    private readonly IFolderStructureService _folderService;
    private readonly ICsvValidationService _csvService;
    private readonly ISessionManager _sessionManager;
    private readonly BulkResultCsvExportService _exportService;
    private readonly ILogger<FeatureViewModelBase> _logger;
    private TenantProfile? _currentProfile;
    private List<FolderStructureRow>? _validRows;
    private BulkOperationSummary<string>? _lastResult;

    [ObservableProperty] private string _siteUrl = string.Empty;
    [ObservableProperty] private string _libraryTitle = string.Empty;
    [ObservableProperty] private string _previewSummary = string.Empty;
    [ObservableProperty] private string _resultSummary = string.Empty;
    [ObservableProperty] private bool _hasFailures;
    [ObservableProperty] private bool _hasPreview;

    private ObservableCollection<CsvValidationRow<FolderStructureRow>> _previewRows = new();
    public ObservableCollection<CsvValidationRow<FolderStructureRow>> PreviewRows
    {
        get => _previewRows;
        private set { _previewRows = value; OnPropertyChanged(); }
    }

    public IRelayCommand ImportCsvCommand { get; }
    public IRelayCommand LoadExampleCommand { get; }
    public IAsyncRelayCommand ExportFailedCommand { get; }

    public Func<string, bool>? ShowConfirmDialog { get; set; }
    public TenantProfile? CurrentProfile => _currentProfile;

    public FolderStructureViewModel(
        IFolderStructureService folderService,
        ICsvValidationService csvService,
        ISessionManager sessionManager,
        BulkResultCsvExportService exportService,
        ILogger<FeatureViewModelBase> logger)
        : base(logger)
    {
        _folderService = folderService;
        _csvService = csvService;
        _sessionManager = sessionManager;
        _exportService = exportService;
        _logger = logger;

        ImportCsvCommand = new RelayCommand(ImportCsv);
        LoadExampleCommand = new RelayCommand(LoadExample);
        ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures);
    }

    private void ImportCsv()
    {
        var dlg = new OpenFileDialog
        {
            Title = TranslationSource.Instance["bulk.csvimport.title"],
            Filter = TranslationSource.Instance["bulk.csvimport.filter"],
        };
        if (dlg.ShowDialog() != true) return;

        using var stream = File.OpenRead(dlg.FileName);
        LoadAndPreview(stream);
    }

    private void LoadExample()
    {
        var assembly = Assembly.GetExecutingAssembly();
        var resourceName = assembly.GetManifestResourceNames()
            .FirstOrDefault(n => n.EndsWith("folder_structure.csv", StringComparison.OrdinalIgnoreCase));
        if (resourceName == null) return;

        using var stream = assembly.GetManifestResourceStream(resourceName);
        if (stream != null) LoadAndPreview(stream);
    }

    private void LoadAndPreview(Stream stream)
    {
        var rows = _csvService.ParseAndValidateFolders(stream);
        PreviewRows = new ObservableCollection<CsvValidationRow<FolderStructureRow>>(rows);
        _validRows = rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList();

        var uniquePaths = FolderStructureService.BuildUniquePaths(_validRows);
        PreviewSummary = string.Format(TranslationSource.Instance["folderstruct.preview"], uniquePaths.Count);
        HasPreview = true;
        ResultSummary = string.Empty;
        HasFailures = false;
    }

    protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
    {
        if (_currentProfile == null) throw new InvalidOperationException("No tenant connected.");
        if (_validRows == null || _validRows.Count == 0)
            throw new InvalidOperationException("No valid rows. Import a CSV first.");
        if (string.IsNullOrWhiteSpace(SiteUrl))
            throw new InvalidOperationException("Site URL is required.");
        if (string.IsNullOrWhiteSpace(LibraryTitle))
            throw new InvalidOperationException("Library title is required.");

        var uniquePaths = FolderStructureService.BuildUniquePaths(_validRows);
        var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
            $"{uniquePaths.Count} folders will be created in {LibraryTitle}");
        if (ShowConfirmDialog != null && !ShowConfirmDialog(message))
            return;

        var profile = new TenantProfile
        {
            Name = _currentProfile.Name,
            TenantUrl = SiteUrl,
            ClientId = _currentProfile.ClientId,
        };
        var ctx = await _sessionManager.GetOrCreateContextAsync(profile, ct);

        _lastResult = await _folderService.CreateFoldersAsync(ctx, LibraryTitle, _validRows, progress, ct);

        await Application.Current.Dispatcher.InvokeAsync(() =>
        {
            HasFailures = _lastResult.HasFailures;
            ExportFailedCommand.NotifyCanExecuteChanged();

            ResultSummary = _lastResult.HasFailures
                ? string.Format(TranslationSource.Instance["bulk.result.success"],
                    _lastResult.SuccessCount, _lastResult.FailedCount)
                : string.Format(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 = "failed_folders.csv" };
        if (dlg.ShowDialog() == true)
        {
            await _exportService.WriteFailedItemsCsvAsync(_lastResult.FailedItems.ToList(), dlg.FileName, CancellationToken.None);
        }
    }

    protected override void OnTenantSwitched(TenantProfile profile)
    {
        _currentProfile = profile;
        SiteUrl = string.Empty;
        LibraryTitle = string.Empty;
        PreviewRows = new();
        _validRows = null;
        PreviewSummary = string.Empty;
        ResultSummary = string.Empty;
        HasFailures = false;
        HasPreview = false;
    }
}

Verify:

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

Done: All three ViewModels compile with Import CSV, Load Example, Validate, Preview, Confirm, Execute, Retry Failed, Export Failed flows.

Task 2: Create BulkMembersView + BulkSitesView + FolderStructureView

Files:

  • SharepointToolbox/Views/Tabs/BulkMembersView.xaml
  • SharepointToolbox/Views/Tabs/BulkMembersView.xaml.cs
  • SharepointToolbox/Views/Tabs/BulkSitesView.xaml
  • SharepointToolbox/Views/Tabs/BulkSitesView.xaml.cs
  • SharepointToolbox/Views/Tabs/FolderStructureView.xaml
  • SharepointToolbox/Views/Tabs/FolderStructureView.xaml.cs

Action:

  1. Create BulkMembersView.xaml:
<UserControl x:Class="SharepointToolbox.Views.Tabs.BulkMembersView"
             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="240" Margin="0,0,10,0">
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.import]}"
                    Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" />
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.example]}"
                    Command="{Binding LoadExampleCommand}" Margin="0,0,0,10" />

            <TextBlock Text="{Binding PreviewSummary}" TextWrapping="Wrap" Margin="0,0,0,10" />

            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.execute]}"
                    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}}" />

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

            <TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5" />
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.retryfailed]}"
                    Command="{Binding RetryFailedCommand}" Margin="0,0,0,5" />
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}"
                    Command="{Binding ExportFailedCommand}" />
        </StackPanel>

        <!-- Preview DataGrid (Right) -->
        <DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False"
                  IsReadOnly="True" CanUserSortColumns="True">
            <DataGrid.Columns>
                <DataGridTextColumn Header="Valid" Binding="{Binding IsValid}" Width="50" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.groupname]}"
                                    Binding="{Binding Record.GroupName}" Width="*" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.email]}"
                                    Binding="{Binding Record.Email}" Width="*" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.role]}"
                                    Binding="{Binding Record.Role}" Width="80" />
                <DataGridTextColumn Header="Errors" Binding="{Binding Errors, Converter={StaticResource ListToStringConverter}}"
                                    Width="*" Foreground="Red" />
            </DataGrid.Columns>
        </DataGrid>
    </DockPanel>
</UserControl>

Note: If ListToStringConverter doesn't exist, create one in Views/Converters/ that joins a List<string> with "; ". Alternatively, the executor can use a simpler approach: bind to Errors[0] or create a ValidationErrors computed property on the view model row wrapper.

  1. Create BulkMembersView.xaml.cs:
using System.Windows.Controls;
using SharepointToolbox.Views.Dialogs;

namespace SharepointToolbox.Views.Tabs;

public partial class BulkMembersView : UserControl
{
    public BulkMembersView(ViewModels.Tabs.BulkMembersViewModel viewModel)
    {
        InitializeComponent();
        DataContext = viewModel;

        viewModel.ShowConfirmDialog = message =>
        {
            var dlg = new ConfirmBulkOperationDialog(message)
                { Owner = System.Windows.Window.GetWindow(this) };
            dlg.ShowDialog();
            return dlg.IsConfirmed;
        };
    }
}
  1. Create BulkSitesView.xaml — same layout as BulkMembersView but with site-specific columns:
<UserControl x:Class="SharepointToolbox.Views.Tabs.BulkSitesView"
             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">
        <StackPanel DockPanel.Dock="Left" Width="240" Margin="0,0,10,0">
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.import]}"
                    Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" />
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.example]}"
                    Command="{Binding LoadExampleCommand}" Margin="0,0,0,10" />

            <TextBlock Text="{Binding PreviewSummary}" TextWrapping="Wrap" Margin="0,0,0,10" />

            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.execute]}"
                    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}}" />

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

            <TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5" />
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.retryfailed]}"
                    Command="{Binding RetryFailedCommand}" Margin="0,0,0,5" />
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}"
                    Command="{Binding ExportFailedCommand}" />
        </StackPanel>

        <DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False"
                  IsReadOnly="True" CanUserSortColumns="True">
            <DataGrid.Columns>
                <DataGridTextColumn Header="Valid" Binding="{Binding IsValid}" Width="50" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.name]}"
                                    Binding="{Binding Record.Name}" Width="*" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.alias]}"
                                    Binding="{Binding Record.Alias}" Width="100" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.type]}"
                                    Binding="{Binding Record.Type}" Width="100" />
                <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.owners]}"
                                    Binding="{Binding Record.Owners}" Width="*" />
                <DataGridTextColumn Header="Errors" Binding="{Binding Errors, Converter={StaticResource ListToStringConverter}}"
                                    Width="*" Foreground="Red" />
            </DataGrid.Columns>
        </DataGrid>
    </DockPanel>
</UserControl>
  1. Create BulkSitesView.xaml.cs:
using System.Windows.Controls;
using SharepointToolbox.Views.Dialogs;

namespace SharepointToolbox.Views.Tabs;

public partial class BulkSitesView : UserControl
{
    public BulkSitesView(ViewModels.Tabs.BulkSitesViewModel viewModel)
    {
        InitializeComponent();
        DataContext = viewModel;

        viewModel.ShowConfirmDialog = message =>
        {
            var dlg = new ConfirmBulkOperationDialog(message)
                { Owner = System.Windows.Window.GetWindow(this) };
            dlg.ShowDialog();
            return dlg.IsConfirmed;
        };
    }
}
  1. Create FolderStructureView.xaml:
<UserControl x:Class="SharepointToolbox.Views.Tabs.FolderStructureView"
             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">
        <StackPanel DockPanel.Dock="Left" Width="280" Margin="0,0,10,0">
            <!-- Site URL and Library inputs -->
            <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.siteurl]}"
                       Margin="0,0,0,3" />
            <TextBox Text="{Binding SiteUrl, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,10" />

            <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.library]}"
                       Margin="0,0,0,3" />
            <TextBox Text="{Binding LibraryTitle, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,10" />

            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.import]}"
                    Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" />
            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.example]}"
                    Command="{Binding LoadExampleCommand}" Margin="0,0,0,10" />

            <TextBlock Text="{Binding PreviewSummary}" TextWrapping="Wrap" Margin="0,0,0,10" />

            <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.execute]}"
                    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}}" />

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

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

        <DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False"
                  IsReadOnly="True" CanUserSortColumns="True">
            <DataGrid.Columns>
                <DataGridTextColumn Header="Valid" Binding="{Binding IsValid}" Width="50" />
                <DataGridTextColumn Header="Level 1" Binding="{Binding Record.Level1}" Width="*" />
                <DataGridTextColumn Header="Level 2" Binding="{Binding Record.Level2}" Width="*" />
                <DataGridTextColumn Header="Level 3" Binding="{Binding Record.Level3}" Width="*" />
                <DataGridTextColumn Header="Level 4" Binding="{Binding Record.Level4}" Width="*" />
                <DataGridTextColumn Header="Errors" Binding="{Binding Errors, Converter={StaticResource ListToStringConverter}}"
                                    Width="*" Foreground="Red" />
            </DataGrid.Columns>
        </DataGrid>
    </DockPanel>
</UserControl>
  1. Create FolderStructureView.xaml.cs:
using System.Windows.Controls;
using SharepointToolbox.Views.Dialogs;

namespace SharepointToolbox.Views.Tabs;

public partial class FolderStructureView : UserControl
{
    public FolderStructureView(ViewModels.Tabs.FolderStructureViewModel viewModel)
    {
        InitializeComponent();
        DataContext = viewModel;

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

Important: If ListToStringConverter does not exist in the project, create SharepointToolbox/Views/Converters/ListToStringConverter.cs:

using System.Globalization;
using System.Windows.Data;

namespace SharepointToolbox.Views.Converters;

public class ListToStringConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if (value is IEnumerable<string> list)
            return string.Join("; ", list);
        return string.Empty;
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        => throw new NotImplementedException();
}

Register it in App.xaml Application.Resources alongside existing converters. Also register EnumBoolConverter if needed by TransferView:

// In App.xaml or wherever converters are registered

Verify:

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

Done: All three CSV tab Views + code-behind compile. Each wires ConfirmBulkOperationDialog for confirmation. DataGrid shows preview with validation indicators. Import CSV, Load Example, Execute, Retry Failed, Export Failed all connected.

Commit: feat(04-09): create BulkMembers, BulkSites, and FolderStructure ViewModels and Views