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>
24 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 | 10 | TemplatesViewModel + TemplatesView + DI Registration + MainWindow Wiring | pending | 3 |
|
|
false |
|
|
Plan 04-10: TemplatesViewModel + TemplatesView + DI Registration + MainWindow Wiring
Goal
Create the Templates tab (ViewModel + View), register ALL Phase 4 services/ViewModels/Views in DI, wire all 5 new tabs in MainWindow, and verify the app launches with all tabs visible.
Context
All services are implemented: FileTransferService (04-03), BulkMemberService (04-04), BulkSiteService (04-05), TemplateService + FolderStructureService (04-06), CsvValidationService (04-02), TemplateRepository (04-02). All ViewModels/Views for Transfer (04-08), BulkMembers/BulkSites/FolderStructure (04-09) are done.
DI pattern: Services as AddTransient<Interface, Implementation>(). ViewModels/Views as AddTransient<Type>(). Infrastructure singletons as AddSingleton<Type>(). Register in App.xaml.cs RegisterServices().
MainWindow pattern: Add x:Name TabItems in XAML, set Content from DI in code-behind constructor.
Current MainWindow.xaml has 3 stub tabs (Templates, Bulk, Structure) with FeatureTabBase. These must be replaced with the 5 new named TabItems.
Tasks
Task 1: Create TemplatesViewModel + TemplatesView
Files:
SharepointToolbox/ViewModels/Tabs/TemplatesViewModel.csSharepointToolbox/Views/Tabs/TemplatesView.xamlSharepointToolbox/Views/Tabs/TemplatesView.xaml.cs
Action:
- Create
TemplatesViewModel.cs:
using System.Collections.ObjectModel;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using Serilog;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Infrastructure.Persistence;
using SharepointToolbox.Localization;
using SharepointToolbox.Services;
namespace SharepointToolbox.ViewModels.Tabs;
public partial class TemplatesViewModel : FeatureViewModelBase
{
private readonly ITemplateService _templateService;
private readonly TemplateRepository _templateRepo;
private readonly ISessionManager _sessionManager;
private readonly ILogger<FeatureViewModelBase> _logger;
private TenantProfile? _currentProfile;
// Template list
private ObservableCollection<SiteTemplate> _templates = new();
public ObservableCollection<SiteTemplate> Templates
{
get => _templates;
private set { _templates = value; OnPropertyChanged(); }
}
[ObservableProperty] private SiteTemplate? _selectedTemplate;
// Capture options
[ObservableProperty] private string _captureSiteUrl = string.Empty;
[ObservableProperty] private string _templateName = string.Empty;
[ObservableProperty] private bool _captureLibraries = true;
[ObservableProperty] private bool _captureFolders = true;
[ObservableProperty] private bool _capturePermissions = true;
[ObservableProperty] private bool _captureLogo = true;
[ObservableProperty] private bool _captureSettings = true;
// Apply options
[ObservableProperty] private string _newSiteTitle = string.Empty;
[ObservableProperty] private string _newSiteAlias = string.Empty;
public IAsyncRelayCommand CaptureCommand { get; }
public IAsyncRelayCommand ApplyCommand { get; }
public IAsyncRelayCommand RenameCommand { get; }
public IAsyncRelayCommand DeleteCommand { get; }
public IAsyncRelayCommand RefreshCommand { get; }
public TenantProfile? CurrentProfile => _currentProfile;
public TemplatesViewModel(
ITemplateService templateService,
TemplateRepository templateRepo,
ISessionManager sessionManager,
ILogger<FeatureViewModelBase> logger)
: base(logger)
{
_templateService = templateService;
_templateRepo = templateRepo;
_sessionManager = sessionManager;
_logger = logger;
CaptureCommand = new AsyncRelayCommand(CaptureAsync, () => !IsRunning);
ApplyCommand = new AsyncRelayCommand(ApplyAsync, () => !IsRunning && SelectedTemplate != null);
RenameCommand = new AsyncRelayCommand(RenameAsync, () => SelectedTemplate != null);
DeleteCommand = new AsyncRelayCommand(DeleteAsync, () => SelectedTemplate != null);
RefreshCommand = new AsyncRelayCommand(RefreshListAsync);
}
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
// Not used directly — Capture and Apply have their own async commands
await Task.CompletedTask;
}
private async Task CaptureAsync()
{
if (_currentProfile == null)
throw new InvalidOperationException("No tenant connected.");
if (string.IsNullOrWhiteSpace(CaptureSiteUrl))
throw new InvalidOperationException("Site URL is required.");
if (string.IsNullOrWhiteSpace(TemplateName))
throw new InvalidOperationException("Template name is required.");
try
{
IsRunning = true;
StatusMessage = "Capturing template...";
var profile = new TenantProfile
{
Name = _currentProfile.Name,
TenantUrl = CaptureSiteUrl,
ClientId = _currentProfile.ClientId,
};
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, CancellationToken.None);
var options = new SiteTemplateOptions
{
CaptureLibraries = CaptureLibraries,
CaptureFolders = CaptureFolders,
CapturePermissionGroups = CapturePermissions,
CaptureLogo = CaptureLogo,
CaptureSettings = CaptureSettings,
};
var progress = new Progress<OperationProgress>(p => StatusMessage = p.Message);
var template = await _templateService.CaptureTemplateAsync(ctx, options, progress, CancellationToken.None);
template.Name = TemplateName;
await _templateRepo.SaveAsync(template);
Log.Information("Template captured: {Name} from {Url}", template.Name, CaptureSiteUrl);
await RefreshListAsync();
StatusMessage = $"Template '{TemplateName}' captured successfully.";
}
catch (Exception ex)
{
StatusMessage = $"Capture failed: {ex.Message}";
Log.Error(ex, "Template capture failed");
}
finally
{
IsRunning = false;
}
}
private async Task ApplyAsync()
{
if (_currentProfile == null || SelectedTemplate == null) return;
if (string.IsNullOrWhiteSpace(NewSiteTitle))
throw new InvalidOperationException("New site title is required.");
if (string.IsNullOrWhiteSpace(NewSiteAlias))
throw new InvalidOperationException("New site alias is required.");
try
{
IsRunning = true;
StatusMessage = $"Applying template '{SelectedTemplate.Name}'...";
var ctx = await _sessionManager.GetOrCreateContextAsync(_currentProfile, CancellationToken.None);
var progress = new Progress<OperationProgress>(p => StatusMessage = p.Message);
var siteUrl = await _templateService.ApplyTemplateAsync(
ctx, SelectedTemplate, NewSiteTitle, NewSiteAlias,
progress, CancellationToken.None);
StatusMessage = $"Template applied. Site created at: {siteUrl}";
Log.Information("Template '{Name}' applied. New site: {Url}", SelectedTemplate.Name, siteUrl);
}
catch (Exception ex)
{
StatusMessage = $"Apply failed: {ex.Message}";
Log.Error(ex, "Template apply failed");
}
finally
{
IsRunning = false;
}
}
private async Task RenameAsync()
{
if (SelectedTemplate == null) return;
// Simple input dialog — use a prompt via code-behind or InputBox
// The View will wire this via a Func<string, string?> factory
if (RenameDialogFactory != null)
{
var newName = RenameDialogFactory(SelectedTemplate.Name);
if (!string.IsNullOrWhiteSpace(newName))
{
await _templateRepo.RenameAsync(SelectedTemplate.Id, newName);
await RefreshListAsync();
Log.Information("Template renamed: {OldName} -> {NewName}", SelectedTemplate.Name, newName);
}
}
}
private async Task DeleteAsync()
{
if (SelectedTemplate == null) return;
await _templateRepo.DeleteAsync(SelectedTemplate.Id);
await RefreshListAsync();
Log.Information("Template deleted: {Name}", SelectedTemplate.Name);
}
private async Task RefreshListAsync()
{
var templates = await _templateRepo.GetAllAsync();
await Application.Current.Dispatcher.InvokeAsync(() =>
{
Templates = new ObservableCollection<SiteTemplate>(templates);
});
}
// Factory for rename dialog — set by View code-behind
public Func<string, string?>? RenameDialogFactory { get; set; }
protected override void OnTenantSwitched(TenantProfile profile)
{
_currentProfile = profile;
CaptureSiteUrl = string.Empty;
TemplateName = string.Empty;
NewSiteTitle = string.Empty;
NewSiteAlias = string.Empty;
StatusMessage = string.Empty;
// Refresh template list on tenant switch
_ = RefreshListAsync();
}
partial void OnSelectedTemplateChanged(SiteTemplate? value)
{
ApplyCommand.NotifyCanExecuteChanged();
RenameCommand.NotifyCanExecuteChanged();
DeleteCommand.NotifyCanExecuteChanged();
}
}
- Create
TemplatesView.xaml:
<UserControl x:Class="SharepointToolbox.Views.Tabs.TemplatesView"
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">
<!-- Left panel: Capture and Apply -->
<StackPanel DockPanel.Dock="Left" Width="320" Margin="0,0,10,0">
<!-- Capture Section -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.capture]}"
Margin="0,0,0,10">
<StackPanel Margin="5">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.siteurl]}"
Margin="0,0,0,3" />
<TextBox Text="{Binding CaptureSiteUrl, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,5" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.name]}"
Margin="0,0,0,3" />
<TextBox Text="{Binding TemplateName, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,10" />
<!-- Capture options checkboxes -->
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.options]}"
FontWeight="SemiBold" Margin="0,0,0,5" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.opt.libraries]}"
IsChecked="{Binding CaptureLibraries}" Margin="0,0,0,3" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.opt.folders]}"
IsChecked="{Binding CaptureFolders}" Margin="0,0,0,3" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.opt.permissions]}"
IsChecked="{Binding CapturePermissions}" Margin="0,0,0,3" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.opt.logo]}"
IsChecked="{Binding CaptureLogo}" Margin="0,0,0,3" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.opt.settings]}"
IsChecked="{Binding CaptureSettings}" Margin="0,0,0,10" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.capture]}"
Command="{Binding CaptureCommand}" />
</StackPanel>
</GroupBox>
<!-- Apply Section -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.apply]}"
Margin="0,0,0,10">
<StackPanel Margin="5">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.newtitle]}"
Margin="0,0,0,3" />
<TextBox Text="{Binding NewSiteTitle, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,5" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.newalias]}"
Margin="0,0,0,3" />
<TextBox Text="{Binding NewSiteAlias, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,10" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.apply]}"
Command="{Binding ApplyCommand}" />
</StackPanel>
</GroupBox>
<!-- Progress -->
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" Margin="0,5,0,0" />
</StackPanel>
<!-- Right panel: Template list -->
<DockPanel>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,5">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.list]}"
FontWeight="Bold" FontSize="14" VerticalAlignment="Center" Margin="0,0,10,0" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.rename]}"
Command="{Binding RenameCommand}" Margin="0,0,5,0" Padding="10,3" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.delete]}"
Command="{Binding DeleteCommand}" Padding="10,3" />
</StackPanel>
<DataGrid ItemsSource="{Binding Templates}" SelectedItem="{Binding SelectedTemplate}"
AutoGenerateColumns="False" IsReadOnly="True"
SelectionMode="Single" CanUserSortColumns="True">
<DataGrid.Columns>
<DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="*" />
<DataGridTextColumn Header="Type" Binding="{Binding SiteType}" Width="100" />
<DataGridTextColumn Header="Source" Binding="{Binding SourceUrl}" Width="*" />
<DataGridTextColumn Header="Captured" Binding="{Binding CapturedAt, StringFormat=yyyy-MM-dd HH:mm}" Width="140" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</DockPanel>
</UserControl>
- Create
TemplatesView.xaml.cs:
using System.Windows;
using System.Windows.Controls;
using Microsoft.VisualBasic;
namespace SharepointToolbox.Views.Tabs;
public partial class TemplatesView : UserControl
{
public TemplatesView(ViewModels.Tabs.TemplatesViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
// Wire rename dialog factory — use simple InputBox
viewModel.RenameDialogFactory = currentName =>
{
// Simple prompt — WPF has no built-in InputBox, use Microsoft.VisualBasic.Interaction.InputBox
// or create a simple dialog. For simplicity, use a MessageBox approach.
var result = Microsoft.VisualBasic.Interaction.InputBox(
"Enter new template name:", "Rename Template", currentName);
return string.IsNullOrWhiteSpace(result) ? null : result;
};
// Load templates on first display
viewModel.RefreshCommand.ExecuteAsync(null);
}
}
Note: If Microsoft.VisualBasic is not available or undesired, create a simple InputDialog Window instead. The executor should check if Microsoft.VisualBasic is referenced (it's part of .NET SDK by default) or create a minimal WPF dialog.
Verify:
dotnet build SharepointToolbox/SharepointToolbox.csproj --no-restore -q
Done: TemplatesViewModel and TemplatesView compile. Template list, capture with checkboxes, apply with title/alias, rename, delete all connected.
Task 2: Register all Phase 4 types in DI + Wire MainWindow tabs
Files:
SharepointToolbox/App.xaml.csSharepointToolbox/MainWindow.xamlSharepointToolbox/MainWindow.xaml.cs
Action:
- Update
App.xaml.cs— add Phase 4 DI registrations inRegisterServices(), after the existing Phase 3 block:
// Add these using statements at the top:
using SharepointToolbox.Infrastructure.Auth;
// (other usings already present)
// Add in RegisterServices(), after Phase 3 block:
// Phase 4: Bulk Operations Infrastructure
var templatesDir = Path.Combine(appData, "templates");
services.AddSingleton(_ => new TemplateRepository(templatesDir));
services.AddSingleton<GraphClientFactory>();
services.AddTransient<ICsvValidationService, CsvValidationService>();
services.AddTransient<BulkResultCsvExportService>();
// Phase 4: File Transfer
services.AddTransient<IFileTransferService, FileTransferService>();
services.AddTransient<TransferViewModel>();
services.AddTransient<TransferView>();
// Phase 4: Bulk Members
services.AddTransient<IBulkMemberService, BulkMemberService>();
services.AddTransient<BulkMembersViewModel>();
services.AddTransient<BulkMembersView>();
// Phase 4: Bulk Sites
services.AddTransient<IBulkSiteService, BulkSiteService>();
services.AddTransient<BulkSitesViewModel>();
services.AddTransient<BulkSitesView>();
// Phase 4: Templates
services.AddTransient<ITemplateService, TemplateService>();
services.AddTransient<TemplatesViewModel>();
services.AddTransient<TemplatesView>();
// Phase 4: Folder Structure
services.AddTransient<IFolderStructureService, FolderStructureService>();
services.AddTransient<FolderStructureViewModel>();
services.AddTransient<FolderStructureView>();
Also add required using statements at top of App.xaml.cs:
using SharepointToolbox.Infrastructure.Auth; // GraphClientFactory
// Other new usings should be covered by existing namespace imports
- Update
MainWindow.xaml— replace the 3 FeatureTabBase stub tabs (Templates, Bulk, Structure) with 5 named TabItems:
Replace:
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.templates]}">
<controls:FeatureTabBase />
</TabItem>
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.bulk]}">
<controls:FeatureTabBase />
</TabItem>
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.structure]}">
<controls:FeatureTabBase />
</TabItem>
With:
<TabItem x:Name="TransferTabItem"
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.transfer]}">
</TabItem>
<TabItem x:Name="BulkMembersTabItem"
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.bulkMembers]}">
</TabItem>
<TabItem x:Name="BulkSitesTabItem"
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.bulkSites]}">
</TabItem>
<TabItem x:Name="FolderStructureTabItem"
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.folderStructure]}">
</TabItem>
<TabItem x:Name="TemplatesTabItem"
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.templates]}">
</TabItem>
Note: Keep the Settings tab at the end. The tab order should be: Permissions, Storage, Search, Duplicates, Transfer, Bulk Members, Bulk Sites, Folder Structure, Templates, Settings.
- Update
MainWindow.xaml.cs— add tab content wiring in the constructor, after existing tab assignments:
// Add after existing DuplicatesTabItem.Content line:
// Phase 4: Replace stub tabs with DI-resolved Views
TransferTabItem.Content = serviceProvider.GetRequiredService<TransferView>();
BulkMembersTabItem.Content = serviceProvider.GetRequiredService<BulkMembersView>();
BulkSitesTabItem.Content = serviceProvider.GetRequiredService<BulkSitesView>();
FolderStructureTabItem.Content = serviceProvider.GetRequiredService<FolderStructureView>();
TemplatesTabItem.Content = serviceProvider.GetRequiredService<TemplatesView>();
Verify:
dotnet build SharepointToolbox.slnx --no-restore -q && dotnet test SharepointToolbox.Tests --no-build -q
Done: All Phase 4 services, ViewModels, and Views registered in DI. All 5 new tabs wired in MainWindow. Application builds and all tests pass.
Task 3: Visual checkpoint
Type: checkpoint:human-verify
What-built: All 5 Phase 4 tabs (Transfer, Bulk Members, Bulk Sites, Folder Structure, Templates) integrated into the application.
How-to-verify:
- Run the application:
dotnet run --project SharepointToolbox/SharepointToolbox.csproj - Verify all 10 tabs are visible: Permissions, Storage, Search, Duplicates, Transfer, Bulk Members, Bulk Sites, Folder Structure, Templates, Settings
- Click each new tab — verify it shows the expected layout (no crash, no blank tab)
- On Bulk Members tab: click "Load Example" — verify the DataGrid populates with sample member data
- On Bulk Sites tab: click "Load Example" — verify the DataGrid populates with sample site data
- On Folder Structure tab: click "Load Example" — verify the DataGrid populates with folder structure data
- On Templates tab: verify the capture options section shows 5 checkboxes (Libraries, Folders, Permission Groups, Logo, Settings)
- On Transfer tab: verify source/destination sections with Browse buttons are visible
Resume-signal: Type "approved" or describe issues.
Commit: feat(04-10): register Phase 4 DI + wire MainWindow tabs + TemplatesView