chore: archive v1.1 Enhanced Reports milestone
Some checks failed
Release SharePoint Toolbox v2 / release (push) Failing after 14s
Some checks failed
Release SharePoint Toolbox v2 / release (push) Failing after 14s
v1.1 shipped with 4 phases (25 plans), 10/10 requirements complete: - Global site selection (toolbar picker, all tabs consume) - User access audit (Graph people-picker, direct/group/inherited) - Simplified permissions (plain-language labels, risk levels, detail toggle) - Storage visualization (LiveCharts2 pie/donut + bar charts) Post-phase polish: centralized site selection (removed per-tab pickers), claims prefix stripping, StorageMetrics backfill, chart tooltip fix, summary stats in app + HTML exports. 205 tests passing, 10,484 LOC. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,10 +33,8 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
||||
private readonly DuplicatesHtmlExportService _htmlExportService;
|
||||
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||
private TenantProfile? _currentProfile;
|
||||
private bool _hasLocalSiteOverride;
|
||||
private IReadOnlyList<DuplicateGroup> _lastGroups = Array.Empty<DuplicateGroup>();
|
||||
|
||||
[ObservableProperty] private string _siteUrl = string.Empty;
|
||||
[ObservableProperty] private bool _modeFiles = true;
|
||||
[ObservableProperty] private bool _modeFolders;
|
||||
[ObservableProperty] private bool _matchSize = true;
|
||||
@@ -77,26 +75,6 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||
}
|
||||
|
||||
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
|
||||
{
|
||||
if (_hasLocalSiteOverride) return;
|
||||
SiteUrl = sites.Count > 0 ? sites[0].Url : string.Empty;
|
||||
}
|
||||
|
||||
partial void OnSiteUrlChanged(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
_hasLocalSiteOverride = false;
|
||||
if (GlobalSites.Count > 0)
|
||||
SiteUrl = GlobalSites[0].Url;
|
||||
}
|
||||
else if (GlobalSites.Count == 0 || value != GlobalSites[0].Url)
|
||||
{
|
||||
_hasLocalSiteOverride = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||
{
|
||||
if (_currentProfile == null)
|
||||
@@ -104,36 +82,45 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
||||
StatusMessage = "No tenant selected. Please connect to a tenant first.";
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(SiteUrl))
|
||||
|
||||
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||
if (urls.Count == 0)
|
||||
{
|
||||
StatusMessage = "Please enter a site URL.";
|
||||
StatusMessage = "Select at least one site from the toolbar.";
|
||||
return;
|
||||
}
|
||||
|
||||
var siteProfile = new TenantProfile
|
||||
var allGroups = new List<DuplicateGroup>();
|
||||
|
||||
foreach (var url in urls)
|
||||
{
|
||||
TenantUrl = SiteUrl.TrimEnd('/'),
|
||||
ClientId = _currentProfile.ClientId,
|
||||
Name = _currentProfile.Name
|
||||
};
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
|
||||
var siteProfile = new TenantProfile
|
||||
{
|
||||
TenantUrl = url.TrimEnd('/'),
|
||||
ClientId = _currentProfile.ClientId,
|
||||
Name = _currentProfile.Name
|
||||
};
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
|
||||
|
||||
var opts = new DuplicateScanOptions(
|
||||
Mode: ModeFiles ? "Files" : "Folders",
|
||||
MatchSize: MatchSize,
|
||||
MatchCreated: MatchCreated,
|
||||
MatchModified: MatchModified,
|
||||
MatchSubfolderCount: MatchSubfolders,
|
||||
MatchFileCount: MatchFileCount,
|
||||
IncludeSubsites: IncludeSubsites,
|
||||
Library: string.IsNullOrWhiteSpace(Library) ? null : Library
|
||||
);
|
||||
var opts = new DuplicateScanOptions(
|
||||
Mode: ModeFiles ? "Files" : "Folders",
|
||||
MatchSize: MatchSize,
|
||||
MatchCreated: MatchCreated,
|
||||
MatchModified: MatchModified,
|
||||
MatchSubfolderCount: MatchSubfolders,
|
||||
MatchFileCount: MatchFileCount,
|
||||
IncludeSubsites: IncludeSubsites,
|
||||
Library: string.IsNullOrWhiteSpace(Library) ? null : Library
|
||||
);
|
||||
|
||||
var groups = await _duplicatesService.ScanDuplicatesAsync(ctx, opts, progress, ct);
|
||||
_lastGroups = groups;
|
||||
var groups = await _duplicatesService.ScanDuplicatesAsync(ctx, opts, progress, ct);
|
||||
allGroups.AddRange(groups);
|
||||
}
|
||||
|
||||
_lastGroups = allGroups;
|
||||
|
||||
// Flatten groups to display rows
|
||||
var rows = groups
|
||||
var rows = allGroups
|
||||
.SelectMany(g => g.Items.Select(item => new DuplicateRow
|
||||
{
|
||||
GroupName = g.Name,
|
||||
@@ -158,10 +145,8 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
||||
protected override void OnTenantSwitched(Core.Models.TenantProfile profile)
|
||||
{
|
||||
_currentProfile = profile;
|
||||
_hasLocalSiteOverride = false;
|
||||
Results = new ObservableCollection<DuplicateRow>();
|
||||
_lastGroups = Array.Empty<DuplicateGroup>();
|
||||
SiteUrl = string.Empty;
|
||||
OnPropertyChanged(nameof(CurrentProfile));
|
||||
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
@@ -22,11 +22,9 @@ public partial class FolderStructureViewModel : FeatureViewModelBase
|
||||
private readonly BulkResultCsvExportService _exportService;
|
||||
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||
private TenantProfile? _currentProfile;
|
||||
private bool _hasLocalSiteOverride;
|
||||
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;
|
||||
@@ -66,26 +64,6 @@ public partial class FolderStructureViewModel : FeatureViewModelBase
|
||||
ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures);
|
||||
}
|
||||
|
||||
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
|
||||
{
|
||||
if (_hasLocalSiteOverride) return;
|
||||
SiteUrl = sites.Count > 0 ? sites[0].Url : string.Empty;
|
||||
}
|
||||
|
||||
partial void OnSiteUrlChanged(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
_hasLocalSiteOverride = false;
|
||||
if (GlobalSites.Count > 0)
|
||||
SiteUrl = GlobalSites[0].Url;
|
||||
}
|
||||
else if (GlobalSites.Count == 0 || value != GlobalSites[0].Url)
|
||||
{
|
||||
_hasLocalSiteOverride = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ImportCsv()
|
||||
{
|
||||
var dlg = new OpenFileDialog
|
||||
@@ -128,26 +106,36 @@ public partial class FolderStructureViewModel : FeatureViewModelBase
|
||||
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 urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||
if (urls.Count == 0)
|
||||
throw new InvalidOperationException("Select at least one site from the toolbar.");
|
||||
|
||||
var uniquePaths = FolderStructureService.BuildUniquePaths(_validRows);
|
||||
var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
|
||||
$"{uniquePaths.Count} folders will be created in {LibraryTitle}");
|
||||
$"{uniquePaths.Count} folders will be created in {LibraryTitle} on {urls.Count} site(s)");
|
||||
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);
|
||||
var allResults = new List<BulkItemResult<string>>();
|
||||
|
||||
_lastResult = await _folderService.CreateFoldersAsync(ctx, LibraryTitle, _validRows, progress, ct);
|
||||
foreach (var url in urls)
|
||||
{
|
||||
var profile = new TenantProfile
|
||||
{
|
||||
Name = _currentProfile.Name,
|
||||
TenantUrl = url,
|
||||
ClientId = _currentProfile.ClientId,
|
||||
};
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, ct);
|
||||
|
||||
var result = await _folderService.CreateFoldersAsync(ctx, LibraryTitle, _validRows, progress, ct);
|
||||
allResults.AddRange(result.Results);
|
||||
}
|
||||
|
||||
_lastResult = new BulkOperationSummary<string>(allResults);
|
||||
|
||||
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
@@ -175,8 +163,6 @@ public partial class FolderStructureViewModel : FeatureViewModelBase
|
||||
protected override void OnTenantSwitched(TenantProfile profile)
|
||||
{
|
||||
_currentProfile = profile;
|
||||
_hasLocalSiteOverride = false;
|
||||
SiteUrl = string.Empty;
|
||||
LibraryTitle = string.Empty;
|
||||
PreviewRows = new();
|
||||
_validRows = null;
|
||||
|
||||
@@ -30,9 +30,6 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
|
||||
// ── Observable properties ───────────────────────────────────────────────
|
||||
|
||||
[ObservableProperty]
|
||||
private string _siteUrl = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _includeInherited;
|
||||
|
||||
@@ -115,43 +112,11 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
|
||||
public IAsyncRelayCommand ExportCsvCommand { get; }
|
||||
public IAsyncRelayCommand ExportHtmlCommand { get; }
|
||||
public RelayCommand OpenSitePickerCommand { get; }
|
||||
|
||||
// ── Multi-site ──────────────────────────────────────────────────────────
|
||||
|
||||
public ObservableCollection<SiteInfo> SelectedSites { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// True when the user has manually selected sites via the site picker on this tab.
|
||||
/// Prevents global site changes from overwriting the user's local selection.
|
||||
/// </summary>
|
||||
private bool _hasLocalSiteOverride;
|
||||
|
||||
// ── 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;
|
||||
|
||||
/// <summary>
|
||||
/// Public accessor for the current tenant profile — used by View layer dialog factory.
|
||||
/// </summary>
|
||||
public TenantProfile? CurrentProfile => _currentProfile;
|
||||
|
||||
/// <summary>
|
||||
/// Label shown in the UI: "3 site(s) selected" or empty when none are selected.
|
||||
/// </summary>
|
||||
public string SitesSelectedLabel =>
|
||||
SelectedSites.Count > 0
|
||||
? string.Format(Localization.TranslationSource.Instance["perm.sites.selected"], SelectedSites.Count)
|
||||
: string.Empty;
|
||||
|
||||
// ── Constructors ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -175,8 +140,6 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
|
||||
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
|
||||
SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -198,21 +161,10 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
|
||||
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
|
||||
SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel));
|
||||
}
|
||||
|
||||
// ── FeatureViewModelBase implementation ─────────────────────────────────
|
||||
|
||||
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
|
||||
{
|
||||
if (_hasLocalSiteOverride) return;
|
||||
|
||||
SelectedSites.Clear();
|
||||
foreach (var site in sites)
|
||||
SelectedSites.Add(site);
|
||||
}
|
||||
|
||||
partial void OnIsSimplifiedModeChanged(bool value)
|
||||
{
|
||||
if (value && Results.Count > 0)
|
||||
@@ -237,17 +189,15 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
|
||||
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||
{
|
||||
var urls = SelectedSites.Count > 0
|
||||
? SelectedSites.Select(s => s.Url).ToList()
|
||||
: new List<string> { SiteUrl };
|
||||
|
||||
var nonEmpty = urls.Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||
if (nonEmpty.Count == 0)
|
||||
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||
if (urls.Count == 0)
|
||||
{
|
||||
StatusMessage = "Enter a site URL or select sites.";
|
||||
StatusMessage = "Select at least one site from the toolbar.";
|
||||
return;
|
||||
}
|
||||
|
||||
var nonEmpty = urls;
|
||||
|
||||
var allEntries = new List<PermissionEntry>();
|
||||
var scanOptions = new ScanOptions(
|
||||
IncludeInherited: IncludeInherited,
|
||||
@@ -303,15 +253,10 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
protected override void OnTenantSwitched(TenantProfile profile)
|
||||
{
|
||||
_currentProfile = profile;
|
||||
_hasLocalSiteOverride = false;
|
||||
Results = new ObservableCollection<PermissionEntry>();
|
||||
SimplifiedResults = Array.Empty<SimplifiedPermissionEntry>();
|
||||
Summaries = Array.Empty<PermissionSummary>();
|
||||
OnPropertyChanged(nameof(ActiveItemsSource));
|
||||
SiteUrl = string.Empty;
|
||||
SelectedSites.Clear();
|
||||
OnPropertyChanged(nameof(SitesSelectedLabel));
|
||||
OnPropertyChanged(nameof(CurrentProfile));
|
||||
ExportCsvCommand.NotifyCanExecuteChanged();
|
||||
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
@@ -381,19 +326,6 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteOpenSitePicker()
|
||||
{
|
||||
if (OpenSitePickerDialog == null) return;
|
||||
var dialog = OpenSitePickerDialog.Invoke();
|
||||
if (dialog?.ShowDialog() == true && dialog is Views.Dialogs.SitePickerDialog picker)
|
||||
{
|
||||
_hasLocalSiteOverride = true;
|
||||
SelectedSites.Clear();
|
||||
foreach (var site in picker.SelectedUrls)
|
||||
SelectedSites.Add(site);
|
||||
}
|
||||
}
|
||||
|
||||
private static void OpenFile(string filePath)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -19,11 +19,9 @@ public partial class SearchViewModel : FeatureViewModelBase
|
||||
private readonly SearchHtmlExportService _htmlExportService;
|
||||
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||
private TenantProfile? _currentProfile;
|
||||
private bool _hasLocalSiteOverride;
|
||||
|
||||
// ── Filter observable properties ─────────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private string _siteUrl = string.Empty;
|
||||
[ObservableProperty] private string _extensions = string.Empty;
|
||||
[ObservableProperty] private string _regex = string.Empty;
|
||||
[ObservableProperty] private bool _useCreatedAfter;
|
||||
@@ -74,26 +72,6 @@ public partial class SearchViewModel : FeatureViewModelBase
|
||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||
}
|
||||
|
||||
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
|
||||
{
|
||||
if (_hasLocalSiteOverride) return;
|
||||
SiteUrl = sites.Count > 0 ? sites[0].Url : string.Empty;
|
||||
}
|
||||
|
||||
partial void OnSiteUrlChanged(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
_hasLocalSiteOverride = false;
|
||||
if (GlobalSites.Count > 0)
|
||||
SiteUrl = GlobalSites[0].Url;
|
||||
}
|
||||
else if (GlobalSites.Count == 0 || value != GlobalSites[0].Url)
|
||||
{
|
||||
_hasLocalSiteOverride = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||
{
|
||||
if (_currentProfile == null)
|
||||
@@ -101,48 +79,54 @@ public partial class SearchViewModel : FeatureViewModelBase
|
||||
StatusMessage = "No tenant selected. Please connect to a tenant first.";
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(SiteUrl))
|
||||
|
||||
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||
if (urls.Count == 0)
|
||||
{
|
||||
StatusMessage = "Please enter a site URL.";
|
||||
StatusMessage = "Select at least one site from the toolbar.";
|
||||
return;
|
||||
}
|
||||
|
||||
var siteProfile = new TenantProfile
|
||||
var allItems = new List<SearchResult>();
|
||||
|
||||
foreach (var url in urls)
|
||||
{
|
||||
TenantUrl = SiteUrl.TrimEnd('/'),
|
||||
ClientId = _currentProfile.ClientId,
|
||||
Name = _currentProfile.Name
|
||||
};
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
|
||||
var siteProfile = new TenantProfile
|
||||
{
|
||||
TenantUrl = url.TrimEnd('/'),
|
||||
ClientId = _currentProfile.ClientId,
|
||||
Name = _currentProfile.Name
|
||||
};
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
|
||||
|
||||
var opts = new SearchOptions(
|
||||
Extensions: ParseExtensions(Extensions),
|
||||
Regex: string.IsNullOrWhiteSpace(Regex) ? null : Regex,
|
||||
CreatedAfter: UseCreatedAfter ? CreatedAfter : null,
|
||||
CreatedBefore: UseCreatedBefore ? CreatedBefore : null,
|
||||
ModifiedAfter: UseModifiedAfter ? ModifiedAfter : null,
|
||||
ModifiedBefore: UseModifiedBefore ? ModifiedBefore : null,
|
||||
CreatedBy: string.IsNullOrWhiteSpace(CreatedBy) ? null : CreatedBy,
|
||||
ModifiedBy: string.IsNullOrWhiteSpace(ModifiedBy) ? null : ModifiedBy,
|
||||
Library: string.IsNullOrWhiteSpace(Library) ? null : Library,
|
||||
MaxResults: Math.Clamp(MaxResults, 1, 50_000),
|
||||
SiteUrl: SiteUrl.TrimEnd('/')
|
||||
);
|
||||
var opts = new SearchOptions(
|
||||
Extensions: ParseExtensions(Extensions),
|
||||
Regex: string.IsNullOrWhiteSpace(Regex) ? null : Regex,
|
||||
CreatedAfter: UseCreatedAfter ? CreatedAfter : null,
|
||||
CreatedBefore: UseCreatedBefore ? CreatedBefore : null,
|
||||
ModifiedAfter: UseModifiedAfter ? ModifiedAfter : null,
|
||||
ModifiedBefore: UseModifiedBefore ? ModifiedBefore : null,
|
||||
CreatedBy: string.IsNullOrWhiteSpace(CreatedBy) ? null : CreatedBy,
|
||||
ModifiedBy: string.IsNullOrWhiteSpace(ModifiedBy) ? null : ModifiedBy,
|
||||
Library: string.IsNullOrWhiteSpace(Library) ? null : Library,
|
||||
MaxResults: Math.Clamp(MaxResults, 1, 50_000),
|
||||
SiteUrl: url.TrimEnd('/')
|
||||
);
|
||||
|
||||
var items = await _searchService.SearchFilesAsync(ctx, opts, progress, ct);
|
||||
var items = await _searchService.SearchFilesAsync(ctx, opts, progress, ct);
|
||||
allItems.AddRange(items);
|
||||
}
|
||||
|
||||
if (Application.Current?.Dispatcher is { } dispatcher)
|
||||
await dispatcher.InvokeAsync(() => Results = new ObservableCollection<SearchResult>(items));
|
||||
await dispatcher.InvokeAsync(() => Results = new ObservableCollection<SearchResult>(allItems));
|
||||
else
|
||||
Results = new ObservableCollection<SearchResult>(items);
|
||||
Results = new ObservableCollection<SearchResult>(allItems);
|
||||
}
|
||||
|
||||
protected override void OnTenantSwitched(Core.Models.TenantProfile profile)
|
||||
{
|
||||
_currentProfile = profile;
|
||||
_hasLocalSiteOverride = false;
|
||||
Results = new ObservableCollection<SearchResult>();
|
||||
SiteUrl = string.Empty;
|
||||
OnPropertyChanged(nameof(CurrentProfile));
|
||||
ExportCsvCommand.NotifyCanExecuteChanged();
|
||||
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||
|
||||
@@ -23,10 +23,6 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
private readonly StorageHtmlExportService _htmlExportService;
|
||||
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||
private TenantProfile? _currentProfile;
|
||||
private bool _hasLocalSiteOverride;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _siteUrl = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _perLibrary = true;
|
||||
@@ -101,11 +97,33 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
{
|
||||
_results = value;
|
||||
OnPropertyChanged();
|
||||
NotifySummaryProperties();
|
||||
ExportCsvCommand.NotifyCanExecuteChanged();
|
||||
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary properties (computed from root-level library nodes) ─────────
|
||||
|
||||
/// <summary>Sum of TotalSizeBytes across root-level library nodes.</summary>
|
||||
public long SummaryTotalSize => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalSizeBytes);
|
||||
|
||||
/// <summary>Sum of VersionSizeBytes across root-level library nodes.</summary>
|
||||
public long SummaryVersionSize => Results.Where(n => n.IndentLevel == 0).Sum(n => n.VersionSizeBytes);
|
||||
|
||||
/// <summary>Sum of TotalFileCount across root-level library nodes.</summary>
|
||||
public long SummaryFileCount => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalFileCount);
|
||||
|
||||
public bool HasResults => Results.Count > 0;
|
||||
|
||||
private void NotifySummaryProperties()
|
||||
{
|
||||
OnPropertyChanged(nameof(SummaryTotalSize));
|
||||
OnPropertyChanged(nameof(SummaryVersionSize));
|
||||
OnPropertyChanged(nameof(SummaryFileCount));
|
||||
OnPropertyChanged(nameof(HasResults));
|
||||
}
|
||||
|
||||
public IAsyncRelayCommand ExportCsvCommand { get; }
|
||||
public IAsyncRelayCommand ExportHtmlCommand { get; }
|
||||
|
||||
@@ -146,26 +164,6 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||
}
|
||||
|
||||
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
|
||||
{
|
||||
if (_hasLocalSiteOverride) return;
|
||||
SiteUrl = sites.Count > 0 ? sites[0].Url : string.Empty;
|
||||
}
|
||||
|
||||
partial void OnSiteUrlChanged(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
_hasLocalSiteOverride = false;
|
||||
if (GlobalSites.Count > 0)
|
||||
SiteUrl = GlobalSites[0].Url;
|
||||
}
|
||||
else if (GlobalSites.Count == 0 || value != GlobalSites[0].Url)
|
||||
{
|
||||
_hasLocalSiteOverride = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||
{
|
||||
if (_currentProfile == null)
|
||||
@@ -173,70 +171,83 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
StatusMessage = "No tenant selected. Please connect to a tenant first.";
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(SiteUrl))
|
||||
|
||||
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||
if (urls.Count == 0)
|
||||
{
|
||||
StatusMessage = "Please enter a site URL.";
|
||||
StatusMessage = "Select at least one site from the toolbar.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a site-specific profile: same ClientId and Name, but TenantUrl points to the
|
||||
// site URL the user entered (may differ from the tenant root).
|
||||
var siteProfile = new TenantProfile
|
||||
{
|
||||
TenantUrl = SiteUrl.TrimEnd('/'),
|
||||
ClientId = _currentProfile.ClientId,
|
||||
Name = _currentProfile.Name
|
||||
};
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
|
||||
var nonEmpty = urls;
|
||||
|
||||
var options = new StorageScanOptions(
|
||||
PerLibrary: PerLibrary,
|
||||
IncludeSubsites: IncludeSubsites,
|
||||
FolderDepth: FolderDepth);
|
||||
|
||||
var nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
|
||||
var allNodes = new List<StorageNode>();
|
||||
var allTypeMetrics = new List<FileTypeMetric>();
|
||||
|
||||
// Flatten tree to one level for DataGrid display (assign IndentLevel during flatten)
|
||||
int i = 0;
|
||||
foreach (var url in nonEmpty)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
i++;
|
||||
progress.Report(new OperationProgress(i, nonEmpty.Count, $"Scanning {url}..."));
|
||||
|
||||
var siteProfile = new TenantProfile
|
||||
{
|
||||
TenantUrl = url.TrimEnd('/'),
|
||||
ClientId = _currentProfile.ClientId,
|
||||
Name = _currentProfile.Name
|
||||
};
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
|
||||
|
||||
var nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
|
||||
|
||||
// Backfill any libraries where StorageMetrics returned zeros
|
||||
await _storageService.BackfillZeroNodesAsync(ctx, nodes, progress, ct);
|
||||
|
||||
allNodes.AddRange(nodes);
|
||||
|
||||
progress.Report(OperationProgress.Indeterminate($"Scanning file types: {url}..."));
|
||||
var typeMetrics = await _storageService.CollectFileTypeMetricsAsync(ctx, progress, ct);
|
||||
allTypeMetrics.AddRange(typeMetrics);
|
||||
}
|
||||
|
||||
// Flatten tree for DataGrid display
|
||||
var flat = new List<StorageNode>();
|
||||
foreach (var node in nodes)
|
||||
foreach (var node in allNodes)
|
||||
FlattenNode(node, 0, flat);
|
||||
|
||||
// Merge file-type metrics across sites (same extension -> sum)
|
||||
var mergedMetrics = allTypeMetrics
|
||||
.GroupBy(m => m.Extension, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => new FileTypeMetric(g.Key, g.Sum(m => m.TotalSizeBytes), g.Sum(m => m.FileCount)))
|
||||
.OrderByDescending(m => m.TotalSizeBytes)
|
||||
.ToList();
|
||||
|
||||
if (Application.Current?.Dispatcher is { } dispatcher)
|
||||
{
|
||||
await dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
Results = new ObservableCollection<StorageNode>(flat);
|
||||
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
Results = new ObservableCollection<StorageNode>(flat);
|
||||
}
|
||||
|
||||
// Collect file-type metrics for chart visualization
|
||||
progress.Report(OperationProgress.Indeterminate("Scanning file types for chart..."));
|
||||
var typeMetrics = await _storageService.CollectFileTypeMetricsAsync(ctx, progress, ct);
|
||||
|
||||
if (Application.Current?.Dispatcher is { } chartDispatcher)
|
||||
{
|
||||
await chartDispatcher.InvokeAsync(() =>
|
||||
{
|
||||
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(typeMetrics);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(typeMetrics);
|
||||
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnTenantSwitched(TenantProfile profile)
|
||||
{
|
||||
_currentProfile = profile;
|
||||
_hasLocalSiteOverride = false;
|
||||
Results = new ObservableCollection<StorageNode>();
|
||||
FileTypeMetrics = new ObservableCollection<FileTypeMetric>();
|
||||
SiteUrl = string.Empty;
|
||||
OnPropertyChanged(nameof(CurrentProfile));
|
||||
ExportCsvCommand.NotifyCanExecuteChanged();
|
||||
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||
@@ -262,7 +273,7 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
if (dialog.ShowDialog() != true) return;
|
||||
try
|
||||
{
|
||||
await _csvExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
|
||||
await _csvExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CancellationToken.None);
|
||||
OpenFile(dialog.FileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -285,7 +296,7 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
if (dialog.ShowDialog() != true) return;
|
||||
try
|
||||
{
|
||||
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
|
||||
await _htmlExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CancellationToken.None);
|
||||
OpenFile(dialog.FileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -323,18 +334,26 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
if (otherSize > 0)
|
||||
chartItems.Add(new FileTypeMetric("Other", otherSize, otherCount));
|
||||
|
||||
// Pie/Donut series
|
||||
// Pie/Donut: one PieSeries per slice (LiveCharts2 requires this for per-slice colors).
|
||||
// Tooltip only shows the hovered slice because each series has exactly one value.
|
||||
double innerRadius = IsDonutChart ? 50 : 0;
|
||||
PieChartSeries = chartItems.Select(m => new PieSeries<long>
|
||||
var pieList = new List<ISeries>();
|
||||
foreach (var m in chartItems)
|
||||
{
|
||||
Values = new[] { m.TotalSizeBytes },
|
||||
Name = m.DisplayLabel,
|
||||
InnerRadius = innerRadius,
|
||||
DataLabelsPosition = LiveChartsCore.Measure.PolarLabelsPosition.Outer,
|
||||
DataLabelsFormatter = point => m.DisplayLabel,
|
||||
ToolTipLabelFormatter = point =>
|
||||
$"{m.DisplayLabel}: {FormatBytes(m.TotalSizeBytes)} ({m.FileCount} files)"
|
||||
}).ToList();
|
||||
pieList.Add(new PieSeries<double>
|
||||
{
|
||||
Values = new[] { (double)m.TotalSizeBytes },
|
||||
Name = m.DisplayLabel,
|
||||
InnerRadius = innerRadius,
|
||||
HoverPushout = 8,
|
||||
MaxRadialColumnWidth = 60,
|
||||
DataLabelsFormatter = _ => m.DisplayLabel,
|
||||
ToolTipLabelFormatter = _ =>
|
||||
$"{m.DisplayLabel}: {FormatBytes(m.TotalSizeBytes)} ({m.FileCount} files)",
|
||||
IsVisibleAtLegend = true,
|
||||
});
|
||||
}
|
||||
PieChartSeries = pieList;
|
||||
|
||||
// Bar chart series
|
||||
BarChartSeries = new ISeries[]
|
||||
@@ -388,6 +407,6 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
private static void OpenFile(string filePath)
|
||||
{
|
||||
try { Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true }); }
|
||||
catch { /* ignore — file may open but this is best-effort */ }
|
||||
catch { /* ignore -- file may open but this is best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
||||
[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;
|
||||
@@ -81,11 +80,13 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
||||
{
|
||||
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.");
|
||||
|
||||
var captureSiteUrl = GlobalSites.Select(s => s.Url).FirstOrDefault(u => !string.IsNullOrWhiteSpace(u));
|
||||
if (string.IsNullOrWhiteSpace(captureSiteUrl))
|
||||
throw new InvalidOperationException("Select at least one site from the toolbar.");
|
||||
|
||||
try
|
||||
{
|
||||
IsRunning = true;
|
||||
@@ -94,7 +95,7 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
||||
var profile = new TenantProfile
|
||||
{
|
||||
Name = _currentProfile.Name,
|
||||
TenantUrl = CaptureSiteUrl,
|
||||
TenantUrl = captureSiteUrl,
|
||||
ClientId = _currentProfile.ClientId,
|
||||
};
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, CancellationToken.None);
|
||||
@@ -113,7 +114,7 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
||||
template.Name = TemplateName;
|
||||
|
||||
await _templateRepo.SaveAsync(template);
|
||||
Log.Information("Template captured: {Name} from {Url}", template.Name, CaptureSiteUrl);
|
||||
Log.Information("Template captured: {Name} from {Url}", template.Name, captureSiteUrl);
|
||||
|
||||
await RefreshListAsync();
|
||||
StatusMessage = $"Template captured successfully.";
|
||||
@@ -200,7 +201,6 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
||||
protected override void OnTenantSwitched(TenantProfile profile)
|
||||
{
|
||||
_currentProfile = profile;
|
||||
CaptureSiteUrl = string.Empty;
|
||||
TemplateName = string.Empty;
|
||||
NewSiteTitle = string.Empty;
|
||||
NewSiteAlias = string.Empty;
|
||||
|
||||
@@ -99,25 +99,9 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
|
||||
public IAsyncRelayCommand ExportCsvCommand { get; }
|
||||
public IAsyncRelayCommand ExportHtmlCommand { get; }
|
||||
public RelayCommand OpenSitePickerCommand { get; }
|
||||
public RelayCommand<GraphUserResult> AddUserCommand { get; }
|
||||
public RelayCommand<GraphUserResult> RemoveUserCommand { get; }
|
||||
|
||||
// ── Multi-site ──────────────────────────────────────────────────────────
|
||||
|
||||
public ObservableCollection<SiteInfo> SelectedSites { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// True when the user has manually selected sites via the site picker.
|
||||
/// Prevents global site changes from overwriting the local selection.
|
||||
/// </summary>
|
||||
private bool _hasLocalSiteOverride;
|
||||
|
||||
// ── Dialog factory ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Factory set by the View layer to open the SitePickerDialog without importing Window into ViewModel.</summary>
|
||||
public Func<Window>? OpenSitePickerDialog { get; set; }
|
||||
|
||||
// ── Current tenant profile ──────────────────────────────────────────────
|
||||
|
||||
internal TenantProfile? _currentProfile;
|
||||
@@ -125,12 +109,6 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
/// <summary>Public accessor for the current tenant profile — used by View layer dialog factory.</summary>
|
||||
public TenantProfile? CurrentProfile => _currentProfile;
|
||||
|
||||
/// <summary>Label shown in the UI: "3 site(s) selected" or empty when none are selected.</summary>
|
||||
public string SitesSelectedLabel =>
|
||||
SelectedSites.Count > 0
|
||||
? $"{SelectedSites.Count} site(s) selected"
|
||||
: string.Empty;
|
||||
|
||||
// ── Constructors ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Full constructor — used by DI and production code.</summary>
|
||||
@@ -152,11 +130,9 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
|
||||
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
|
||||
AddUserCommand = new RelayCommand<GraphUserResult>(ExecuteAddUser);
|
||||
RemoveUserCommand = new RelayCommand<GraphUserResult>(ExecuteRemoveUser);
|
||||
|
||||
SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel));
|
||||
SelectedUsers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SelectedUsersLabel));
|
||||
|
||||
var cvs = new CollectionViewSource { Source = Results };
|
||||
@@ -181,11 +157,9 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
|
||||
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
|
||||
AddUserCommand = new RelayCommand<GraphUserResult>(ExecuteAddUser);
|
||||
RemoveUserCommand = new RelayCommand<GraphUserResult>(ExecuteRemoveUser);
|
||||
|
||||
SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel));
|
||||
SelectedUsers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SelectedUsersLabel));
|
||||
|
||||
var cvs = new CollectionViewSource { Source = Results };
|
||||
@@ -195,15 +169,6 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
|
||||
// ── FeatureViewModelBase implementation ─────────────────────────────────
|
||||
|
||||
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
|
||||
{
|
||||
if (_hasLocalSiteOverride) return;
|
||||
|
||||
SelectedSites.Clear();
|
||||
foreach (var site in sites)
|
||||
SelectedSites.Add(site);
|
||||
}
|
||||
|
||||
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||
{
|
||||
if (SelectedUsers.Count == 0)
|
||||
@@ -212,16 +177,14 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveSites = SelectedSites.Count > 0
|
||||
? SelectedSites.ToList()
|
||||
: GlobalSites.ToList();
|
||||
|
||||
if (effectiveSites.Count == 0)
|
||||
if (GlobalSites.Count == 0)
|
||||
{
|
||||
StatusMessage = "Select at least one site to scan.";
|
||||
StatusMessage = "Select at least one site from the toolbar.";
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveSites = GlobalSites.ToList();
|
||||
|
||||
var userLogins = SelectedUsers.Select(u => u.UserPrincipalName).ToList();
|
||||
var scanOptions = new ScanOptions(
|
||||
IncludeInherited: IncludeInherited,
|
||||
@@ -244,19 +207,24 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
progress,
|
||||
ct);
|
||||
|
||||
// Update Results on the UI thread
|
||||
// Update Results on the UI thread — clear + repopulate (not replace)
|
||||
// so the CollectionViewSource bound to ResultsView stays connected.
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
if (dispatcher != null)
|
||||
{
|
||||
await dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
Results = new ObservableCollection<UserAccessEntry>(entries);
|
||||
Results.Clear();
|
||||
foreach (var entry in entries)
|
||||
Results.Add(entry);
|
||||
NotifySummaryProperties();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
Results = new ObservableCollection<UserAccessEntry>(entries);
|
||||
Results.Clear();
|
||||
foreach (var entry in entries)
|
||||
Results.Add(entry);
|
||||
NotifySummaryProperties();
|
||||
}
|
||||
|
||||
@@ -269,14 +237,11 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
protected override void OnTenantSwitched(TenantProfile profile)
|
||||
{
|
||||
_currentProfile = profile;
|
||||
_hasLocalSiteOverride = false;
|
||||
Results = new ObservableCollection<UserAccessEntry>();
|
||||
Results.Clear();
|
||||
SelectedUsers.Clear();
|
||||
SearchQuery = string.Empty;
|
||||
SearchResults.Clear();
|
||||
SelectedSites.Clear();
|
||||
FilterText = string.Empty;
|
||||
OnPropertyChanged(nameof(SitesSelectedLabel));
|
||||
OnPropertyChanged(nameof(CurrentProfile));
|
||||
NotifySummaryProperties();
|
||||
ExportCsvCommand.NotifyCanExecuteChanged();
|
||||
@@ -308,15 +273,10 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
|
||||
partial void OnResultsChanged(ObservableCollection<UserAccessEntry> value)
|
||||
{
|
||||
// Rebind CollectionViewSource when the collection reference changes
|
||||
if (ResultsView is CollectionView cv && cv.SourceCollection != value)
|
||||
{
|
||||
// CollectionViewSource.View is already live-bound in constructor;
|
||||
// for a new collection reference we need to refresh grouping/filter
|
||||
ApplyGrouping();
|
||||
ResultsView.Filter = FilterPredicate;
|
||||
ResultsView.Refresh();
|
||||
}
|
||||
// Safety net: if the collection reference ever changes, rebind grouping/filter
|
||||
ApplyGrouping();
|
||||
ResultsView.Filter = FilterPredicate;
|
||||
ResultsView.Refresh();
|
||||
NotifySummaryProperties();
|
||||
}
|
||||
|
||||
@@ -379,19 +339,6 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteOpenSitePicker()
|
||||
{
|
||||
if (OpenSitePickerDialog == null) return;
|
||||
var dialog = OpenSitePickerDialog.Invoke();
|
||||
if (dialog?.ShowDialog() == true && dialog is Views.Dialogs.SitePickerDialog picker)
|
||||
{
|
||||
_hasLocalSiteOverride = true;
|
||||
SelectedSites.Clear();
|
||||
foreach (var site in picker.SelectedUrls)
|
||||
SelectedSites.Add(site);
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteAddUser(GraphUserResult? user)
|
||||
{
|
||||
if (user == null) return;
|
||||
|
||||
Reference in New Issue
Block a user