feat(02-06): implement PermissionsViewModel with multi-site scan and SitePickerDialog
- PermissionsViewModel extends FeatureViewModelBase, implements RunOperationAsync - Multi-site mode: loops SelectedSites; single-site mode: uses SiteUrl - ExportCsvCommand and ExportHtmlCommand enabled only when Results.Count > 0 - OpenSitePickerCommand uses dialog factory pattern (Func<Window>?) - OnTenantSwitched clears Results, SiteUrl, SelectedSites - Flat ObservableProperty booleans (IncludeInherited, ScanFolders, etc.) build ScanOptions record - SitePickerDialog XAML: filterable list with CheckBox column, Title, URL columns - SitePickerDialog code-behind: loads sites on Window.Loaded, exposes SelectedUrls - ISessionManager interface extracted for testability (SessionManager implements it) - StartScanAsync_WithMultipleSiteUrls_CallsServiceOncePerUrl test passes (60/60 + 3 skip)
This commit is contained in:
@@ -6,6 +6,7 @@ using Microsoft.SharePoint.Client;
|
|||||||
using Moq;
|
using Moq;
|
||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
using SharepointToolbox.Services;
|
using SharepointToolbox.Services;
|
||||||
|
using SharepointToolbox.ViewModels;
|
||||||
using SharepointToolbox.ViewModels.Tabs;
|
using SharepointToolbox.ViewModels.Tabs;
|
||||||
|
|
||||||
namespace SharepointToolbox.Tests.ViewModels;
|
namespace SharepointToolbox.Tests.ViewModels;
|
||||||
@@ -40,7 +41,7 @@ public class PermissionsViewModelTests
|
|||||||
mockPermissionsService.Object,
|
mockPermissionsService.Object,
|
||||||
mockSiteListService.Object,
|
mockSiteListService.Object,
|
||||||
mockSessionManager.Object,
|
mockSessionManager.Object,
|
||||||
new NullLogger<PermissionsViewModel>());
|
new NullLogger<FeatureViewModelBase>());
|
||||||
|
|
||||||
// Set up two site URLs via SelectedSites
|
// Set up two site URLs via SelectedSites
|
||||||
vm.SelectedSites.Add(new SiteInfo("https://tenant1.sharepoint.com/sites/alpha", "Alpha"));
|
vm.SelectedSites.Add(new SiteInfo("https://tenant1.sharepoint.com/sites/alpha", "Alpha"));
|
||||||
|
|||||||
@@ -1,47 +1,273 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Threading;
|
using System.Diagnostics;
|
||||||
using System.Threading.Tasks;
|
using System.Windows;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using CommunityToolkit.Mvvm.Messaging;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
using SharepointToolbox.Core.Messages;
|
||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
using SharepointToolbox.Services;
|
using SharepointToolbox.Services;
|
||||||
|
using SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
namespace SharepointToolbox.ViewModels.Tabs;
|
namespace SharepointToolbox.ViewModels.Tabs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// STUB: PermissionsViewModel — RED phase. Not yet implemented.
|
/// ViewModel for the Permissions tab.
|
||||||
|
/// Orchestrates permission scanning across one or multiple SharePoint sites
|
||||||
|
/// and exports results to CSV or HTML.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class PermissionsViewModel : FeatureViewModelBase
|
public partial class PermissionsViewModel : FeatureViewModelBase
|
||||||
{
|
{
|
||||||
private readonly IPermissionsService _permissionsService;
|
private readonly IPermissionsService _permissionsService;
|
||||||
private readonly ISiteListService _siteListService;
|
private readonly ISiteListService _siteListService;
|
||||||
private readonly ISessionManager _sessionManager;
|
private readonly ISessionManager _sessionManager;
|
||||||
|
private readonly CsvExportService? _csvExportService;
|
||||||
|
private readonly HtmlExportService? _htmlExportService;
|
||||||
|
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||||
|
|
||||||
|
// ── Observable properties ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _siteUrl = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _includeInherited;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _scanFolders = true;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _includeSubsites;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private int _folderDepth = 1;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private ObservableCollection<PermissionEntry> _results = new();
|
||||||
|
|
||||||
|
// ── Commands ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public IAsyncRelayCommand ExportCsvCommand { get; }
|
||||||
|
public IAsyncRelayCommand ExportHtmlCommand { get; }
|
||||||
|
public RelayCommand OpenSitePickerCommand { get; }
|
||||||
|
|
||||||
|
// ── Multi-site ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public ObservableCollection<SiteInfo> SelectedSites { get; } = new();
|
public ObservableCollection<SiteInfo> SelectedSites { get; } = new();
|
||||||
public ObservableCollection<PermissionEntry> Results { get; private set; } = new();
|
|
||||||
|
// ── Dialog factory (set by View layer — keeps Window out of ViewModel) ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Factory function set by the View layer to open the SitePickerDialog.
|
||||||
|
/// Returns the opened Window; ViewModel calls ShowDialog() on it.
|
||||||
|
/// </summary>
|
||||||
|
public Func<Window>? OpenSitePickerDialog { get; set; }
|
||||||
|
|
||||||
|
// ── Current tenant profile (received via WeakReferenceMessenger) ────────
|
||||||
|
|
||||||
internal TenantProfile? _currentProfile;
|
internal TenantProfile? _currentProfile;
|
||||||
|
|
||||||
|
// ── Constructors ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full constructor — used by DI and production code.
|
||||||
|
/// </summary>
|
||||||
public PermissionsViewModel(
|
public PermissionsViewModel(
|
||||||
IPermissionsService permissionsService,
|
IPermissionsService permissionsService,
|
||||||
ISiteListService siteListService,
|
ISiteListService siteListService,
|
||||||
ISessionManager sessionManager,
|
ISessionManager sessionManager,
|
||||||
ILogger<PermissionsViewModel> logger)
|
CsvExportService csvExportService,
|
||||||
|
HtmlExportService htmlExportService,
|
||||||
|
ILogger<FeatureViewModelBase> logger)
|
||||||
: base(logger)
|
: base(logger)
|
||||||
{
|
{
|
||||||
_permissionsService = permissionsService;
|
_permissionsService = permissionsService;
|
||||||
_siteListService = siteListService;
|
_siteListService = siteListService;
|
||||||
_sessionManager = sessionManager;
|
_sessionManager = sessionManager;
|
||||||
|
_csvExportService = csvExportService;
|
||||||
|
_htmlExportService = htmlExportService;
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||||
|
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||||
|
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
|
/// <summary>
|
||||||
|
/// Test constructor — omits export services (not needed for unit tests).
|
||||||
|
/// </summary>
|
||||||
|
internal PermissionsViewModel(
|
||||||
|
IPermissionsService permissionsService,
|
||||||
|
ISiteListService siteListService,
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
ILogger<FeatureViewModelBase> logger)
|
||||||
|
: base(logger)
|
||||||
|
{
|
||||||
|
_permissionsService = permissionsService;
|
||||||
|
_siteListService = siteListService;
|
||||||
|
_sessionManager = sessionManager;
|
||||||
|
_csvExportService = null;
|
||||||
|
_htmlExportService = null;
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
internal Task TestRunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||||
=> RunOperationAsync(ct, progress);
|
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||||
|
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FeatureViewModelBase implementation ─────────────────────────────────
|
||||||
|
|
||||||
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||||
{
|
{
|
||||||
// RED STUB: always throws to make tests fail at RED phase
|
var urls = SelectedSites.Count > 0
|
||||||
throw new NotImplementedException("PermissionsViewModel.RunOperationAsync not yet implemented.");
|
? SelectedSites.Select(s => s.Url).ToList()
|
||||||
|
: new List<string> { SiteUrl };
|
||||||
|
|
||||||
|
var nonEmpty = urls.Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||||
|
if (nonEmpty.Count == 0)
|
||||||
|
{
|
||||||
|
StatusMessage = "Enter a site URL or select sites.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var allEntries = new List<PermissionEntry>();
|
||||||
|
var scanOptions = new ScanOptions(
|
||||||
|
IncludeInherited: IncludeInherited,
|
||||||
|
ScanFolders: ScanFolders,
|
||||||
|
FolderDepth: FolderDepth,
|
||||||
|
IncludeSubsites: IncludeSubsites);
|
||||||
|
|
||||||
|
int i = 0;
|
||||||
|
foreach (var url in nonEmpty)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
progress.Report(new OperationProgress(i, nonEmpty.Count, $"Scanning {url}..."));
|
||||||
|
|
||||||
|
var profile = new TenantProfile
|
||||||
|
{
|
||||||
|
TenantUrl = url,
|
||||||
|
ClientId = _currentProfile?.ClientId ?? string.Empty,
|
||||||
|
Name = _currentProfile?.Name ?? string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, ct);
|
||||||
|
var siteEntries = await _permissionsService.ScanSiteAsync(ctx, scanOptions, progress, ct);
|
||||||
|
allEntries.AddRange(siteEntries);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Results on the UI thread (no-op if no Dispatcher in tests)
|
||||||
|
var dispatcher = Application.Current?.Dispatcher;
|
||||||
|
if (dispatcher != null)
|
||||||
|
{
|
||||||
|
await dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
Results = new ObservableCollection<PermissionEntry>(allEntries);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Results = new ObservableCollection<PermissionEntry>(allEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
ExportCsvCommand.NotifyCanExecuteChanged();
|
||||||
|
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tenant switching ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
protected override void OnTenantSwitched(TenantProfile profile)
|
||||||
|
{
|
||||||
|
_currentProfile = profile;
|
||||||
|
Results = new ObservableCollection<PermissionEntry>();
|
||||||
|
SiteUrl = string.Empty;
|
||||||
|
SelectedSites.Clear();
|
||||||
|
ExportCsvCommand.NotifyCanExecuteChanged();
|
||||||
|
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Sets the current tenant profile (for test injection).</summary>
|
||||||
|
internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
|
||||||
|
|
||||||
|
/// <summary>Exposes RunOperationAsync for unit tests (internal + InternalsVisibleTo).</summary>
|
||||||
|
internal Task TestRunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||||
|
=> RunOperationAsync(ct, progress);
|
||||||
|
|
||||||
|
// ── Command implementations ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
private bool CanExport() => Results.Count > 0;
|
||||||
|
|
||||||
|
private async Task ExportCsvAsync()
|
||||||
|
{
|
||||||
|
if (_csvExportService == null || Results.Count == 0) return;
|
||||||
|
var dialog = new SaveFileDialog
|
||||||
|
{
|
||||||
|
Title = "Export permissions to CSV",
|
||||||
|
Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*",
|
||||||
|
DefaultExt = "csv",
|
||||||
|
FileName = "permissions"
|
||||||
|
};
|
||||||
|
if (dialog.ShowDialog() != true) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _csvExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
|
||||||
|
OpenFile(dialog.FileName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Export failed: {ex.Message}";
|
||||||
|
_logger.LogError(ex, "CSV export failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExportHtmlAsync()
|
||||||
|
{
|
||||||
|
if (_htmlExportService == null || Results.Count == 0) return;
|
||||||
|
var dialog = new SaveFileDialog
|
||||||
|
{
|
||||||
|
Title = "Export permissions to HTML",
|
||||||
|
Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
|
||||||
|
DefaultExt = "html",
|
||||||
|
FileName = "permissions"
|
||||||
|
};
|
||||||
|
if (dialog.ShowDialog() != true) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
|
||||||
|
OpenFile(dialog.FileName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Export failed: {ex.Message}";
|
||||||
|
_logger.LogError(ex, "HTML export failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ExecuteOpenSitePicker()
|
||||||
|
{
|
||||||
|
if (OpenSitePickerDialog == null) return;
|
||||||
|
var dialog = OpenSitePickerDialog.Invoke();
|
||||||
|
if (dialog?.ShowDialog() == true && dialog is Views.Dialogs.SitePickerDialog picker)
|
||||||
|
{
|
||||||
|
SelectedSites.Clear();
|
||||||
|
foreach (var site in picker.SelectedUrls)
|
||||||
|
SelectedSites.Add(site);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OpenFile(string filePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true });
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Non-critical: file was written successfully, just can't auto-open
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
60
SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml
Normal file
60
SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<Window x:Class="SharepointToolbox.Views.Dialogs.SitePickerDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="Select Sites" Width="600" Height="500"
|
||||||
|
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
||||||
|
Loaded="Window_Loaded">
|
||||||
|
<Grid Margin="12">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Filter row -->
|
||||||
|
<DockPanel Grid.Row="0" Margin="0,0,0,8">
|
||||||
|
<TextBlock Text="Filter:" VerticalAlignment="Center" Margin="0,0,8,0" />
|
||||||
|
<TextBox x:Name="FilterBox" TextChanged="FilterBox_TextChanged" />
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Site list with checkboxes -->
|
||||||
|
<ListView x:Name="SiteList" Grid.Row="1" Margin="0,0,0,8"
|
||||||
|
SelectionMode="Single"
|
||||||
|
BorderThickness="1" BorderBrush="#CCCCCC">
|
||||||
|
<ListView.View>
|
||||||
|
<GridView>
|
||||||
|
<GridViewColumn Header="" Width="32">
|
||||||
|
<GridViewColumn.CellTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
HorizontalAlignment="Center" />
|
||||||
|
</DataTemplate>
|
||||||
|
</GridViewColumn.CellTemplate>
|
||||||
|
</GridViewColumn>
|
||||||
|
<GridViewColumn Header="Title" Width="200" DisplayMemberBinding="{Binding Title}" />
|
||||||
|
<GridViewColumn Header="URL" Width="320" DisplayMemberBinding="{Binding Url}" />
|
||||||
|
</GridView>
|
||||||
|
</ListView.View>
|
||||||
|
</ListView>
|
||||||
|
|
||||||
|
<!-- Status text -->
|
||||||
|
<TextBlock x:Name="StatusText" Grid.Row="2" Margin="0,0,0,8"
|
||||||
|
Foreground="#555555" FontSize="11" />
|
||||||
|
|
||||||
|
<!-- Button row -->
|
||||||
|
<DockPanel Grid.Row="3">
|
||||||
|
<Button x:Name="LoadButton" Content="Load Sites" Width="80" Margin="0,0,8,0"
|
||||||
|
Click="LoadButton_Click" />
|
||||||
|
<Button Content="Select All" Width="80" Margin="0,0,8,0"
|
||||||
|
Click="SelectAll_Click" />
|
||||||
|
<Button Content="Deselect All" Width="80" Margin="0,0,8,0"
|
||||||
|
Click="DeselectAll_Click" />
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" DockPanel.Dock="Right">
|
||||||
|
<Button Content="OK" Width="70" Margin="4,0" IsDefault="True"
|
||||||
|
Click="OK_Click" />
|
||||||
|
<Button Content="Cancel" Width="70" Margin="4,0" IsCancel="True" />
|
||||||
|
</StackPanel>
|
||||||
|
</DockPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
127
SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml.cs
Normal file
127
SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml.cs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Services;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Views.Dialogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dialog for selecting multiple SharePoint sites.
|
||||||
|
/// Loads sites from ISiteListService, shows them in a filterable list with checkboxes.
|
||||||
|
/// </summary>
|
||||||
|
public partial class SitePickerDialog : Window
|
||||||
|
{
|
||||||
|
private readonly ISiteListService _siteListService;
|
||||||
|
private readonly TenantProfile _profile;
|
||||||
|
private List<SitePickerItem> _allItems = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the list of sites the user checked before clicking OK.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<SiteInfo> SelectedUrls =>
|
||||||
|
_allItems.Where(i => i.IsSelected).Select(i => new SiteInfo(i.Url, i.Title)).ToList();
|
||||||
|
|
||||||
|
public SitePickerDialog(ISiteListService siteListService, TenantProfile profile)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_siteListService = siteListService;
|
||||||
|
_profile = profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void Window_Loaded(object sender, RoutedEventArgs e) => await LoadSitesAsync();
|
||||||
|
|
||||||
|
private async Task LoadSitesAsync()
|
||||||
|
{
|
||||||
|
StatusText.Text = "Loading sites...";
|
||||||
|
LoadButton.IsEnabled = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sites = await _siteListService.GetSitesAsync(
|
||||||
|
_profile,
|
||||||
|
new Progress<OperationProgress>(),
|
||||||
|
System.Threading.CancellationToken.None);
|
||||||
|
|
||||||
|
_allItems = sites.Select(s => new SitePickerItem(s.Url, s.Title)).ToList();
|
||||||
|
ApplyFilter();
|
||||||
|
StatusText.Text = $"{_allItems.Count} sites loaded.";
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
StatusText.Text = ex.Message;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusText.Text = $"Error: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
LoadButton.IsEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyFilter()
|
||||||
|
{
|
||||||
|
var filter = FilterBox.Text.Trim();
|
||||||
|
SiteList.ItemsSource = string.IsNullOrEmpty(filter)
|
||||||
|
? (IEnumerable<SitePickerItem>)_allItems
|
||||||
|
: _allItems.Where(i =>
|
||||||
|
i.Url.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
i.Title.Contains(filter, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FilterBox_TextChanged(object sender, TextChangedEventArgs e) => ApplyFilter();
|
||||||
|
|
||||||
|
private void SelectAll_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
foreach (var item in _allItems) item.IsSelected = true;
|
||||||
|
ApplyFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeselectAll_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
foreach (var item in _allItems) item.IsSelected = false;
|
||||||
|
ApplyFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void LoadButton_Click(object sender, RoutedEventArgs e) => await LoadSitesAsync();
|
||||||
|
|
||||||
|
private void OK_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
DialogResult = true;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mutable wrapper for a site entry shown in the SitePickerDialog list.
|
||||||
|
/// Supports two-way CheckBox binding via INotifyPropertyChanged.
|
||||||
|
/// </summary>
|
||||||
|
public class SitePickerItem : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
private bool _isSelected;
|
||||||
|
|
||||||
|
public string Url { get; init; } = string.Empty;
|
||||||
|
public string Title { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public bool IsSelected
|
||||||
|
{
|
||||||
|
get => _isSelected;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_isSelected == value) return;
|
||||||
|
_isSelected = value;
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsSelected)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
public SitePickerItem(string url, string title)
|
||||||
|
{
|
||||||
|
Url = url;
|
||||||
|
Title = title;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user