feat(04-10): create TemplatesViewModel and TemplatesView
- TemplatesViewModel: list, capture with 5 options, apply, rename, delete, refresh - TemplatesView: capture section with checkboxes, apply section, template DataGrid - RenameInputDialog: simple WPF dialog (no Microsoft.VisualBasic dependency) - Capture/Apply are separate async commands from RunCommand
This commit is contained in:
218
SharepointToolbox/ViewModels/Tabs/TemplatesViewModel.cs
Normal file
218
SharepointToolbox/ViewModels/Tabs/TemplatesViewModel.cs
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
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.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;
|
||||||
|
|
||||||
|
// Factory for rename dialog — set by View code-behind
|
||||||
|
public Func<string, string?>? RenameDialogFactory { get; set; }
|
||||||
|
|
||||||
|
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 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...";
|
||||||
|
|
||||||
|
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 applied. New site: {Url}", 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;
|
||||||
|
|
||||||
|
if (RenameDialogFactory != null)
|
||||||
|
{
|
||||||
|
var newName = RenameDialogFactory(SelectedTemplate.Name);
|
||||||
|
if (!string.IsNullOrWhiteSpace(newName))
|
||||||
|
{
|
||||||
|
await _templateRepo.RenameAsync(SelectedTemplate.Id, newName);
|
||||||
|
await RefreshListAsync();
|
||||||
|
Log.Information("Template renamed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAsync()
|
||||||
|
{
|
||||||
|
if (SelectedTemplate == null) return;
|
||||||
|
|
||||||
|
await _templateRepo.DeleteAsync(SelectedTemplate.Id);
|
||||||
|
await RefreshListAsync();
|
||||||
|
Log.Information("Template deleted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshListAsync()
|
||||||
|
{
|
||||||
|
var templates = await _templateRepo.GetAllAsync();
|
||||||
|
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
Templates = new ObservableCollection<SiteTemplate>(templates);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnTenantSwitched(TenantProfile profile)
|
||||||
|
{
|
||||||
|
_currentProfile = profile;
|
||||||
|
CaptureSiteUrl = string.Empty;
|
||||||
|
TemplateName = string.Empty;
|
||||||
|
NewSiteTitle = string.Empty;
|
||||||
|
NewSiteAlias = string.Empty;
|
||||||
|
StatusMessage = string.Empty;
|
||||||
|
|
||||||
|
_ = RefreshListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedTemplateChanged(SiteTemplate? value)
|
||||||
|
{
|
||||||
|
ApplyCommand.NotifyCanExecuteChanged();
|
||||||
|
RenameCommand.NotifyCanExecuteChanged();
|
||||||
|
DeleteCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
83
SharepointToolbox/Views/Tabs/TemplatesView.xaml
Normal file
83
SharepointToolbox/Views/Tabs/TemplatesView.xaml
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<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>
|
||||||
71
SharepointToolbox/Views/Tabs/TemplatesView.xaml.cs
Normal file
71
SharepointToolbox/Views/Tabs/TemplatesView.xaml.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Views.Tabs;
|
||||||
|
|
||||||
|
public partial class TemplatesView : UserControl
|
||||||
|
{
|
||||||
|
public TemplatesView(ViewModels.Tabs.TemplatesViewModel viewModel)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
DataContext = viewModel;
|
||||||
|
|
||||||
|
// Wire rename dialog factory using a simple WPF input dialog
|
||||||
|
viewModel.RenameDialogFactory = currentName =>
|
||||||
|
{
|
||||||
|
var dialog = new RenameInputDialog(currentName) { Owner = Window.GetWindow(this) };
|
||||||
|
return dialog.ShowDialog() == true ? dialog.InputText : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load templates on first display
|
||||||
|
viewModel.RefreshCommand.ExecuteAsync(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simple WPF input dialog for renaming templates (replaces Microsoft.VisualBasic.Interaction.InputBox).
|
||||||
|
/// </summary>
|
||||||
|
internal class RenameInputDialog : Window
|
||||||
|
{
|
||||||
|
private readonly TextBox _inputBox;
|
||||||
|
|
||||||
|
public string InputText => _inputBox.Text;
|
||||||
|
|
||||||
|
public RenameInputDialog(string currentName)
|
||||||
|
{
|
||||||
|
Title = "Rename Template";
|
||||||
|
Width = 360;
|
||||||
|
Height = 140;
|
||||||
|
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||||
|
ResizeMode = ResizeMode.NoResize;
|
||||||
|
|
||||||
|
var panel = new StackPanel { Margin = new Thickness(12) };
|
||||||
|
panel.Children.Add(new TextBlock { Text = "Enter new template name:", Margin = new Thickness(0, 0, 0, 6) });
|
||||||
|
|
||||||
|
_inputBox = new TextBox { Text = currentName };
|
||||||
|
_inputBox.SelectAll();
|
||||||
|
_inputBox.Focus();
|
||||||
|
_inputBox.KeyDown += (s, e) =>
|
||||||
|
{
|
||||||
|
if (e.Key == System.Windows.Input.Key.Enter) { DialogResult = true; Close(); }
|
||||||
|
if (e.Key == System.Windows.Input.Key.Escape) { DialogResult = false; Close(); }
|
||||||
|
};
|
||||||
|
panel.Children.Add(_inputBox);
|
||||||
|
|
||||||
|
var buttons = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
|
Margin = new Thickness(0, 10, 0, 0),
|
||||||
|
};
|
||||||
|
var ok = new Button { Content = "OK", Width = 75, Margin = new Thickness(0, 0, 8, 0), IsDefault = true };
|
||||||
|
ok.Click += (s, e) => { DialogResult = true; Close(); };
|
||||||
|
var cancel = new Button { Content = "Cancel", Width = 75, IsCancel = true };
|
||||||
|
cancel.Click += (s, e) => { DialogResult = false; Close(); };
|
||||||
|
buttons.Children.Add(ok);
|
||||||
|
buttons.Children.Add(cancel);
|
||||||
|
panel.Children.Add(buttons);
|
||||||
|
|
||||||
|
Content = panel;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user