--- phase: 04 plan: 09 title: BulkMembersViewModel + BulkSitesViewModel + FolderStructureViewModel + Views status: pending wave: 3 depends_on: - 04-02 - 04-04 - 04-05 - 04-06 - 04-07 files_modified: - 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 autonomous: true requirements: - BULK-02 - BULK-03 - BULK-04 - BULK-05 - FOLD-01 - FOLD-02 must_haves: truths: - "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" artifacts: - path: "SharepointToolbox/ViewModels/Tabs/BulkMembersViewModel.cs" provides: "Bulk Members tab ViewModel" exports: ["BulkMembersViewModel"] - path: "SharepointToolbox/ViewModels/Tabs/BulkSitesViewModel.cs" provides: "Bulk Sites tab ViewModel" exports: ["BulkSitesViewModel"] - path: "SharepointToolbox/ViewModels/Tabs/FolderStructureViewModel.cs" provides: "Folder Structure tab ViewModel" exports: ["FolderStructureViewModel"] - path: "SharepointToolbox/Views/Tabs/BulkMembersView.xaml" provides: "Bulk Members tab UI" - path: "SharepointToolbox/Views/Tabs/BulkSitesView.xaml" provides: "Bulk Sites tab UI" - path: "SharepointToolbox/Views/Tabs/FolderStructureView.xaml" provides: "Folder Structure tab UI" key_links: - from: "BulkMembersViewModel.cs" to: "IBulkMemberService.AddMembersAsync" via: "RunOperationAsync override" pattern: "AddMembersAsync" - from: "BulkSitesViewModel.cs" to: "IBulkSiteService.CreateSitesAsync" via: "RunOperationAsync override" pattern: "CreateSitesAsync" - from: "FolderStructureViewModel.cs" to: "IFolderStructureService.CreateFoldersAsync" via: "RunOperationAsync override" pattern: "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`: ```csharp 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 _logger; private TenantProfile? _currentProfile; private List? _validRows; private List? _failedRowsForRetry; private BulkOperationSummary? _lastResult; [ObservableProperty] private string _previewSummary = string.Empty; [ObservableProperty] private string _resultSummary = string.Empty; [ObservableProperty] private bool _hasFailures; [ObservableProperty] private bool _hasPreview; private ObservableCollection> _previewRows = new(); public ObservableCollection> 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? ShowConfirmDialog { get; set; } public TenantProfile? CurrentProfile => _currentProfile; public BulkMembersViewModel( IBulkMemberService memberService, ICsvValidationService csvService, ISessionManager sessionManager, BulkResultCsvExportService exportService, ILogger 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>(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 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; } } ``` 2. Create `BulkSitesViewModel.cs` — follows same pattern as BulkMembersViewModel but uses `IBulkSiteService` and `BulkSiteRow`: ```csharp 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 _logger; private TenantProfile? _currentProfile; private List? _validRows; private List? _failedRowsForRetry; private BulkOperationSummary? _lastResult; [ObservableProperty] private string _previewSummary = string.Empty; [ObservableProperty] private string _resultSummary = string.Empty; [ObservableProperty] private bool _hasFailures; [ObservableProperty] private bool _hasPreview; private ObservableCollection> _previewRows = new(); public ObservableCollection> 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? ShowConfirmDialog { get; set; } public TenantProfile? CurrentProfile => _currentProfile; public BulkSitesViewModel( IBulkSiteService siteService, ICsvValidationService csvService, ISessionManager sessionManager, BulkResultCsvExportService exportService, ILogger 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>(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 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; } } ``` 3. Create `FolderStructureViewModel.cs`: ```csharp 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 _logger; private TenantProfile? _currentProfile; private List? _validRows; private BulkOperationSummary? _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> _previewRows = new(); public ObservableCollection> PreviewRows { get => _previewRows; private set { _previewRows = value; OnPropertyChanged(); } } public IRelayCommand ImportCsvCommand { get; } public IRelayCommand LoadExampleCommand { get; } public IAsyncRelayCommand ExportFailedCommand { get; } public Func? ShowConfirmDialog { get; set; } public TenantProfile? CurrentProfile => _currentProfile; public FolderStructureViewModel( IFolderStructureService folderService, ICsvValidationService csvService, ISessionManager sessionManager, BulkResultCsvExportService exportService, ILogger 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>(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 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:** ```bash 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`: ```xml