feat(13-02): add directory browse mode with paginated load, member/guest filter, and sortable ICollectionView

- Inject IGraphUserDirectoryService into UserAccessAuditViewModel (both constructors)
- Add IsBrowseMode toggle, DirectoryUsers collection, DirectoryUsersView with sort/filter
- Add LoadDirectoryCommand with progress reporting, cancellation, and error handling
- Add IncludeGuests toggle for in-memory member/guest filtering (no new Graph request)
- Add DirectoryFilterText for DisplayName/UPN/Department/JobTitle text search
- Add DirectoryUserCount computed property reflecting filtered view count
- Update OnTenantSwitched to clear all directory state
- Add 16 comprehensive unit tests covering all directory browse behaviors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dev
2026-04-08 16:07:53 +02:00
parent cb7995ab31
commit 4ba4de6106
2 changed files with 530 additions and 1 deletions

View File

@@ -0,0 +1,351 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.ViewModels;
using SharepointToolbox.ViewModels.Tabs;
namespace SharepointToolbox.Tests.ViewModels;
/// <summary>
/// Unit tests for directory browse mode in UserAccessAuditViewModel (Phase 13 Plan 02).
/// Verifies: directory load, progress, cancellation, member/guest filter, text filter,
/// sorting, tenant switch reset, and no regression on search mode.
/// </summary>
[Trait("Category", "Unit")]
public class UserAccessAuditViewModelDirectoryTests
{
public UserAccessAuditViewModelDirectoryTests()
{
WeakReferenceMessenger.Default.Reset();
}
// ── Helper factories ──────────────────────────────────────────────────────
private static GraphDirectoryUser MakeMember(string name = "Alice", string dept = "IT", string jobTitle = "Engineer") =>
new(name, $"{name.ToLower().Replace(" ", "")}@contoso.com", null, dept, jobTitle, "Member");
private static GraphDirectoryUser MakeGuest(string name = "Bob External") =>
new(name, $"{name.ToLower().Replace(" ", "")}@external.com", null, null, null, "Guest");
private static (UserAccessAuditViewModel vm, Mock<IGraphUserDirectoryService> dirMock, Mock<IUserAccessAuditService> auditMock)
CreateViewModel(IReadOnlyList<GraphDirectoryUser>? directoryResult = null)
{
var mockAudit = new Mock<IUserAccessAuditService>();
var mockGraph = new Mock<IGraphUserSearchService>();
var mockSession = new Mock<ISessionManager>();
var mockDir = new Mock<IGraphUserDirectoryService>();
mockDir.Setup(s => s.GetUsersAsync(
It.IsAny<string>(),
It.IsAny<bool>(),
It.IsAny<IProgress<int>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(directoryResult ?? Array.Empty<GraphDirectoryUser>());
var vm = new UserAccessAuditViewModel(
mockAudit.Object,
mockGraph.Object,
mockSession.Object,
NullLogger<FeatureViewModelBase>.Instance,
graphUserDirectoryService: mockDir.Object);
vm._currentProfile = new TenantProfile
{
Name = "Test",
TenantUrl = "https://contoso.sharepoint.com",
ClientId = "test-client-id"
};
return (vm, mockDir, mockAudit);
}
// ── Test 1: IsBrowseMode defaults to false ───────────────────────────────
[Fact]
public void IsBrowseMode_defaults_to_false()
{
var (vm, _, _) = CreateViewModel();
Assert.False(vm.IsBrowseMode);
}
// ── Test 2: DirectoryUsers is empty by default ───────────────────────────
[Fact]
public void DirectoryUsers_empty_by_default()
{
var (vm, _, _) = CreateViewModel();
Assert.Empty(vm.DirectoryUsers);
}
// ── Test 3: Commands are not null ─────────────────────────────────────────
[Fact]
public void LoadDirectoryCommand_and_CancelDirectoryLoadCommand_not_null()
{
var (vm, _, _) = CreateViewModel();
Assert.NotNull(vm.LoadDirectoryCommand);
Assert.NotNull(vm.CancelDirectoryLoadCommand);
}
// ── Test 4: LoadDirectoryAsync populates DirectoryUsers ──────────────────
[Fact]
public async Task LoadDirectoryAsync_populates_DirectoryUsers()
{
var users = new List<GraphDirectoryUser> { MakeMember("Alice"), MakeMember("Charlie") };
var (vm, _, _) = CreateViewModel(users);
await vm.TestLoadDirectoryAsync();
Assert.Equal(2, vm.DirectoryUsers.Count);
Assert.Contains(vm.DirectoryUsers, u => u.DisplayName == "Alice");
Assert.Contains(vm.DirectoryUsers, u => u.DisplayName == "Charlie");
}
// ── Test 5: LoadDirectoryAsync reports progress via DirectoryLoadStatus ──
[Fact]
public async Task LoadDirectoryAsync_sets_DirectoryLoadStatus_on_completion()
{
var users = new List<GraphDirectoryUser> { MakeMember("Alice") };
var (vm, _, _) = CreateViewModel(users);
await vm.TestLoadDirectoryAsync();
Assert.Equal("1 users loaded", vm.DirectoryLoadStatus);
}
// ── Test 6: LoadDirectoryAsync with no profile sets StatusMessage ─────────
[Fact]
public async Task LoadDirectoryAsync_with_no_profile_sets_StatusMessage()
{
var (vm, _, _) = CreateViewModel();
vm._currentProfile = null;
await vm.TestLoadDirectoryAsync();
Assert.Equal("No tenant profile selected. Please connect first.", vm.StatusMessage);
Assert.Empty(vm.DirectoryUsers);
}
// ── Test 7: CancelDirectoryLoadCommand cancels in-flight load ────────────
[Fact]
public async Task CancelDirectoryLoad_cancels_inflight_load()
{
var tcs = new TaskCompletionSource<IReadOnlyList<GraphDirectoryUser>>();
var mockDir = new Mock<IGraphUserDirectoryService>();
mockDir.Setup(s => s.GetUsersAsync(
It.IsAny<string>(),
It.IsAny<bool>(),
It.IsAny<IProgress<int>>(),
It.IsAny<CancellationToken>()))
.Returns<string, bool, IProgress<int>?, CancellationToken>((_, _, _, ct) =>
{
var localTcs = new TaskCompletionSource<IReadOnlyList<GraphDirectoryUser>>();
ct.Register(() => localTcs.TrySetCanceled(ct));
return localTcs.Task;
});
var vm = new UserAccessAuditViewModel(
new Mock<IUserAccessAuditService>().Object,
new Mock<IGraphUserSearchService>().Object,
new Mock<ISessionManager>().Object,
NullLogger<FeatureViewModelBase>.Instance,
graphUserDirectoryService: mockDir.Object);
vm._currentProfile = new TenantProfile
{
Name = "Test",
TenantUrl = "https://contoso.sharepoint.com",
ClientId = "test-client-id"
};
// Start load (will block on the mock)
var loadTask = vm.TestLoadDirectoryAsync();
// Cancel
vm.CancelDirectoryLoadCommand.Execute(null);
await loadTask;
Assert.Equal("Load cancelled.", vm.DirectoryLoadStatus);
Assert.False(vm.IsLoadingDirectory);
}
// ── Test 8: IncludeGuests=false filters out Guest users ──────────────────
[Fact]
public void IncludeGuests_false_filters_out_guest_users()
{
var (vm, _, _) = CreateViewModel();
vm.DirectoryUsers.Add(MakeMember("Alice"));
vm.DirectoryUsers.Add(MakeGuest("Bob External"));
vm.DirectoryUsers.Add(MakeMember("Charlie"));
vm.IncludeGuests = false;
var visible = vm.DirectoryUsersView.Cast<GraphDirectoryUser>().ToList();
Assert.Equal(2, visible.Count);
Assert.All(visible, u => Assert.Equal("Member", u.UserType));
}
// ── Test 9: IncludeGuests=true shows all users ───────────────────────────
[Fact]
public void IncludeGuests_true_shows_all_users()
{
var (vm, _, _) = CreateViewModel();
vm.DirectoryUsers.Add(MakeMember("Alice"));
vm.DirectoryUsers.Add(MakeGuest("Bob External"));
vm.IncludeGuests = true;
var visible = vm.DirectoryUsersView.Cast<GraphDirectoryUser>().ToList();
Assert.Equal(2, visible.Count);
}
// ── Test 10: DirectoryFilterText filters by DisplayName ──────────────────
[Fact]
public void DirectoryFilterText_filters_by_DisplayName()
{
var (vm, _, _) = CreateViewModel();
vm.DirectoryUsers.Add(MakeMember("Alice"));
vm.DirectoryUsers.Add(MakeMember("Charlie"));
vm.IncludeGuests = true;
vm.DirectoryFilterText = "Ali";
var visible = vm.DirectoryUsersView.Cast<GraphDirectoryUser>().ToList();
Assert.Single(visible);
Assert.Equal("Alice", visible[0].DisplayName);
}
// ── Test 11: DirectoryFilterText filters by Department ───────────────────
[Fact]
public void DirectoryFilterText_filters_by_Department()
{
var (vm, _, _) = CreateViewModel();
vm.DirectoryUsers.Add(MakeMember("Alice", dept: "Engineering"));
vm.DirectoryUsers.Add(MakeMember("Charlie", dept: "Marketing"));
vm.IncludeGuests = true;
vm.DirectoryFilterText = "Market";
var visible = vm.DirectoryUsersView.Cast<GraphDirectoryUser>().ToList();
Assert.Single(visible);
Assert.Equal("Charlie", visible[0].DisplayName);
}
// ── Test 12: DirectoryUsersView default sort is DisplayName ascending ────
[Fact]
public void DirectoryUsersView_sorted_by_DisplayName_ascending()
{
var (vm, _, _) = CreateViewModel();
vm.DirectoryUsers.Add(MakeMember("Charlie"));
vm.DirectoryUsers.Add(MakeMember("Alice"));
vm.DirectoryUsers.Add(MakeMember("Bob"));
vm.IncludeGuests = true;
var visible = vm.DirectoryUsersView.Cast<GraphDirectoryUser>().ToList();
Assert.Equal("Alice", visible[0].DisplayName);
Assert.Equal("Bob", visible[1].DisplayName);
Assert.Equal("Charlie", visible[2].DisplayName);
}
// ── Test 13: OnTenantSwitched clears directory state ─────────────────────
[Fact]
public async Task OnTenantSwitched_clears_directory_state()
{
var users = new List<GraphDirectoryUser> { MakeMember("Alice") };
var (vm, _, _) = CreateViewModel(users);
// Load directory
await vm.TestLoadDirectoryAsync();
Assert.NotEmpty(vm.DirectoryUsers);
vm.IsBrowseMode = true;
vm.DirectoryFilterText = "test";
vm.IncludeGuests = true;
// Act: switch tenant
var newProfile = new TenantProfile
{
Name = "NewTenant",
TenantUrl = "https://newtenant.sharepoint.com",
ClientId = "new-client-id"
};
WeakReferenceMessenger.Default.Send(new Core.Messages.TenantSwitchedMessage(newProfile));
// Assert
Assert.Empty(vm.DirectoryUsers);
Assert.False(vm.IsBrowseMode);
Assert.Empty(vm.DirectoryFilterText);
Assert.Empty(vm.DirectoryLoadStatus);
Assert.False(vm.IsLoadingDirectory);
Assert.False(vm.IncludeGuests);
}
// ── Test 14: DirectoryUserCount reflects filtered count ───────────────────
[Fact]
public void DirectoryUserCount_reflects_filtered_count()
{
var (vm, _, _) = CreateViewModel();
vm.DirectoryUsers.Add(MakeMember("Alice"));
vm.DirectoryUsers.Add(MakeGuest("Bob External"));
vm.DirectoryUsers.Add(MakeMember("Charlie"));
// With guests hidden (default IncludeGuests=false)
vm.IncludeGuests = false;
Assert.Equal(2, vm.DirectoryUserCount);
// With guests shown
vm.IncludeGuests = true;
Assert.Equal(3, vm.DirectoryUserCount);
// With text filter
vm.DirectoryFilterText = "Ali";
Assert.Equal(1, vm.DirectoryUserCount);
}
// ── Test 15: Search mode still works (no regression) ─────────────────────
[Fact]
public void Search_mode_SelectedUsers_still_works()
{
var (vm, _, _) = CreateViewModel();
// Search mode properties should be functional
Assert.Empty(vm.SelectedUsers);
vm.SelectedUsers.Add(new GraphUserResult("Alice Smith", "alice@contoso.com", "alice@contoso.com"));
Assert.Single(vm.SelectedUsers);
Assert.Equal("1 user(s) selected", vm.SelectedUsersLabel);
}
// ── Test 16: DirectoryFilterText filters by JobTitle ─────────────────────
[Fact]
public void DirectoryFilterText_filters_by_JobTitle()
{
var (vm, _, _) = CreateViewModel();
vm.DirectoryUsers.Add(MakeMember("Alice", jobTitle: "Senior Developer"));
vm.DirectoryUsers.Add(MakeMember("Charlie", jobTitle: "Product Manager"));
vm.IncludeGuests = true;
vm.DirectoryFilterText = "Developer";
var visible = vm.DirectoryUsersView.Cast<GraphDirectoryUser>().ToList();
Assert.Single(visible);
Assert.Equal("Alice", visible[0].DisplayName);
}
}

View File

@@ -26,6 +26,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
private readonly UserAccessCsvExportService? _csvExportService; private readonly UserAccessCsvExportService? _csvExportService;
private readonly UserAccessHtmlExportService? _htmlExportService; private readonly UserAccessHtmlExportService? _htmlExportService;
private readonly IBrandingService? _brandingService; private readonly IBrandingService? _brandingService;
private readonly IGraphUserDirectoryService? _graphUserDirectoryService;
private readonly ILogger<FeatureViewModelBase> _logger; private readonly ILogger<FeatureViewModelBase> _logger;
// ── People picker debounce ────────────────────────────────────────────── // ── People picker debounce ──────────────────────────────────────────────
@@ -74,6 +75,34 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
[ObservableProperty] [ObservableProperty]
private bool _isSearching; private bool _isSearching;
// ── Directory browse mode properties ───────────────────────────────────
/// <summary>When true, the UI shows the directory browse panel instead of the people-picker search.</summary>
[ObservableProperty]
private bool _isBrowseMode;
/// <summary>All directory users loaded from Graph.</summary>
[ObservableProperty]
private ObservableCollection<GraphDirectoryUser> _directoryUsers = new();
/// <summary>True while a directory load is in progress.</summary>
[ObservableProperty]
private bool _isLoadingDirectory;
/// <summary>Status text for directory load progress, e.g. "Loading... 500 users".</summary>
[ObservableProperty]
private string _directoryLoadStatus = string.Empty;
/// <summary>When true, guest users are shown in the directory view; when false, only members.</summary>
[ObservableProperty]
private bool _includeGuests;
/// <summary>Text filter applied to DirectoryUsersView (DisplayName, UPN, Department, JobTitle).</summary>
[ObservableProperty]
private string _directoryFilterText = string.Empty;
private CancellationTokenSource? _directoryCts = null;
// ── Computed summary properties ───────────────────────────────────────── // ── Computed summary properties ─────────────────────────────────────────
/// <summary>Total number of access entries in current results.</summary> /// <summary>Total number of access entries in current results.</summary>
@@ -91,17 +120,25 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
? $"{SelectedUsers.Count} user(s) selected" ? $"{SelectedUsers.Count} user(s) selected"
: string.Empty; : string.Empty;
/// <summary>Number of users currently visible in the filtered directory view.</summary>
public int DirectoryUserCount => DirectoryUsersView?.Cast<object>().Count() ?? 0;
// ── CollectionViewSource (grouping + filtering) ───────────────────────── // ── CollectionViewSource (grouping + filtering) ─────────────────────────
/// <summary>ICollectionView over Results supporting grouping and text filtering.</summary> /// <summary>ICollectionView over Results supporting grouping and text filtering.</summary>
public ICollectionView ResultsView { get; } public ICollectionView ResultsView { get; }
/// <summary>ICollectionView over DirectoryUsers with member/guest and text filtering, sorted by DisplayName.</summary>
public ICollectionView DirectoryUsersView { get; }
// ── Commands ──────────────────────────────────────────────────────────── // ── Commands ────────────────────────────────────────────────────────────
public IAsyncRelayCommand ExportCsvCommand { get; } public IAsyncRelayCommand ExportCsvCommand { get; }
public IAsyncRelayCommand ExportHtmlCommand { get; } public IAsyncRelayCommand ExportHtmlCommand { get; }
public RelayCommand<GraphUserResult> AddUserCommand { get; } public RelayCommand<GraphUserResult> AddUserCommand { get; }
public RelayCommand<GraphUserResult> RemoveUserCommand { get; } public RelayCommand<GraphUserResult> RemoveUserCommand { get; }
public IAsyncRelayCommand LoadDirectoryCommand { get; }
public RelayCommand CancelDirectoryLoadCommand { get; }
// ── Current tenant profile ────────────────────────────────────────────── // ── Current tenant profile ──────────────────────────────────────────────
@@ -120,6 +157,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
UserAccessCsvExportService csvExportService, UserAccessCsvExportService csvExportService,
UserAccessHtmlExportService htmlExportService, UserAccessHtmlExportService htmlExportService,
IBrandingService brandingService, IBrandingService brandingService,
IGraphUserDirectoryService graphUserDirectoryService,
ILogger<FeatureViewModelBase> logger) ILogger<FeatureViewModelBase> logger)
: base(logger) : base(logger)
{ {
@@ -129,6 +167,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
_csvExportService = csvExportService; _csvExportService = csvExportService;
_htmlExportService = htmlExportService; _htmlExportService = htmlExportService;
_brandingService = brandingService; _brandingService = brandingService;
_graphUserDirectoryService = graphUserDirectoryService;
_logger = logger; _logger = logger;
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
@@ -141,6 +180,17 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
var cvs = new CollectionViewSource { Source = Results }; var cvs = new CollectionViewSource { Source = Results };
ResultsView = cvs.View; ResultsView = cvs.View;
ApplyGrouping(); ApplyGrouping();
var dirCvs = new CollectionViewSource { Source = DirectoryUsers };
DirectoryUsersView = dirCvs.View;
DirectoryUsersView.SortDescriptions.Add(
new SortDescription(nameof(GraphDirectoryUser.DisplayName), ListSortDirection.Ascending));
DirectoryUsersView.Filter = DirectoryFilterPredicate;
LoadDirectoryCommand = new AsyncRelayCommand(LoadDirectoryAsync, () => !IsLoadingDirectory);
CancelDirectoryLoadCommand = new RelayCommand(
() => _directoryCts?.Cancel(),
() => IsLoadingDirectory);
} }
/// <summary>Test constructor — omits export services (not needed for unit tests).</summary> /// <summary>Test constructor — omits export services (not needed for unit tests).</summary>
@@ -149,7 +199,8 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
IGraphUserSearchService graphUserSearchService, IGraphUserSearchService graphUserSearchService,
ISessionManager sessionManager, ISessionManager sessionManager,
ILogger<FeatureViewModelBase> logger, ILogger<FeatureViewModelBase> logger,
IBrandingService? brandingService = null) IBrandingService? brandingService = null,
IGraphUserDirectoryService? graphUserDirectoryService = null)
: base(logger) : base(logger)
{ {
_auditService = auditService; _auditService = auditService;
@@ -158,6 +209,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
_csvExportService = null; _csvExportService = null;
_htmlExportService = null; _htmlExportService = null;
_brandingService = brandingService; _brandingService = brandingService;
_graphUserDirectoryService = graphUserDirectoryService;
_logger = logger; _logger = logger;
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
@@ -170,6 +222,17 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
var cvs = new CollectionViewSource { Source = Results }; var cvs = new CollectionViewSource { Source = Results };
ResultsView = cvs.View; ResultsView = cvs.View;
ApplyGrouping(); ApplyGrouping();
var dirCvs = new CollectionViewSource { Source = DirectoryUsers };
DirectoryUsersView = dirCvs.View;
DirectoryUsersView.SortDescriptions.Add(
new SortDescription(nameof(GraphDirectoryUser.DisplayName), ListSortDirection.Ascending));
DirectoryUsersView.Filter = DirectoryFilterPredicate;
LoadDirectoryCommand = new AsyncRelayCommand(LoadDirectoryAsync, () => !IsLoadingDirectory);
CancelDirectoryLoadCommand = new RelayCommand(
() => _directoryCts?.Cancel(),
() => IsLoadingDirectory);
} }
// ── FeatureViewModelBase implementation ───────────────────────────────── // ── FeatureViewModelBase implementation ─────────────────────────────────
@@ -251,6 +314,18 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
NotifySummaryProperties(); NotifySummaryProperties();
ExportCsvCommand.NotifyCanExecuteChanged(); ExportCsvCommand.NotifyCanExecuteChanged();
ExportHtmlCommand.NotifyCanExecuteChanged(); ExportHtmlCommand.NotifyCanExecuteChanged();
// Directory browse mode reset
_directoryCts?.Cancel();
_directoryCts?.Dispose();
_directoryCts = null;
DirectoryUsers.Clear();
DirectoryFilterText = string.Empty;
DirectoryLoadStatus = string.Empty;
IsBrowseMode = false;
IsLoadingDirectory = false;
IncludeGuests = false;
OnPropertyChanged(nameof(DirectoryUserCount));
} }
// ── Observable property change handlers ───────────────────────────────── // ── Observable property change handlers ─────────────────────────────────
@@ -285,6 +360,24 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
NotifySummaryProperties(); NotifySummaryProperties();
} }
partial void OnIncludeGuestsChanged(bool value)
{
DirectoryUsersView.Refresh();
OnPropertyChanged(nameof(DirectoryUserCount));
}
partial void OnDirectoryFilterTextChanged(string value)
{
DirectoryUsersView.Refresh();
OnPropertyChanged(nameof(DirectoryUserCount));
}
partial void OnIsLoadingDirectoryChanged(bool value)
{
LoadDirectoryCommand.NotifyCanExecuteChanged();
CancelDirectoryLoadCommand.NotifyCanExecuteChanged();
}
// ── Internal helpers ───────────────────────────────────────────────────── // ── Internal helpers ─────────────────────────────────────────────────────
/// <summary>Sets the current tenant profile (for test injection).</summary> /// <summary>Sets the current tenant profile (for test injection).</summary>
@@ -294,6 +387,91 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
internal Task TestRunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress) internal Task TestRunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
=> RunOperationAsync(ct, progress); => RunOperationAsync(ct, progress);
// ── Directory browse mode ──────────────────────────────────────────────
private async Task LoadDirectoryAsync()
{
if (_graphUserDirectoryService is null) return;
var clientId = _currentProfile?.ClientId;
if (string.IsNullOrEmpty(clientId))
{
StatusMessage = "No tenant profile selected. Please connect first.";
return;
}
_directoryCts?.Cancel();
_directoryCts?.Dispose();
_directoryCts = new CancellationTokenSource();
var ct = _directoryCts.Token;
IsLoadingDirectory = true;
DirectoryLoadStatus = "Loading...";
try
{
var progress = new Progress<int>(count =>
DirectoryLoadStatus = $"Loading... {count} users");
var users = await _graphUserDirectoryService.GetUsersAsync(
clientId, includeGuests: true, progress, ct);
ct.ThrowIfCancellationRequested();
var dispatcher = Application.Current?.Dispatcher;
if (dispatcher != null)
{
await dispatcher.InvokeAsync(() => PopulateDirectory(users));
}
else
{
PopulateDirectory(users);
}
DirectoryLoadStatus = $"{users.Count} users loaded";
}
catch (OperationCanceledException)
{
DirectoryLoadStatus = "Load cancelled.";
}
catch (Exception ex)
{
DirectoryLoadStatus = $"Failed: {ex.Message}";
_logger.LogError(ex, "Directory load failed.");
}
finally
{
IsLoadingDirectory = false;
}
}
private void PopulateDirectory(IReadOnlyList<GraphDirectoryUser> users)
{
DirectoryUsers.Clear();
foreach (var u in users)
DirectoryUsers.Add(u);
DirectoryUsersView.Refresh();
OnPropertyChanged(nameof(DirectoryUserCount));
}
private bool DirectoryFilterPredicate(object obj)
{
if (obj is not GraphDirectoryUser user) return false;
// Member/guest filter
if (!IncludeGuests && !string.Equals(user.UserType, "Member", StringComparison.OrdinalIgnoreCase))
return false;
// Text filter
if (string.IsNullOrWhiteSpace(DirectoryFilterText)) return true;
var filter = DirectoryFilterText.Trim();
return user.DisplayName.Contains(filter, StringComparison.OrdinalIgnoreCase)
|| user.UserPrincipalName.Contains(filter, StringComparison.OrdinalIgnoreCase)
|| (user.Department?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false)
|| (user.JobTitle?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false);
}
/// <summary>Exposes LoadDirectoryAsync for unit tests (internal + InternalsVisibleTo).</summary>
internal Task TestLoadDirectoryAsync() => LoadDirectoryAsync();
// ── Command implementations ─────────────────────────────────────────────── // ── Command implementations ───────────────────────────────────────────────
private bool CanExport() => Results.Count > 0; private bool CanExport() => Results.Count > 0;