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:
@@ -26,6 +26,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
private readonly UserAccessCsvExportService? _csvExportService;
|
||||
private readonly UserAccessHtmlExportService? _htmlExportService;
|
||||
private readonly IBrandingService? _brandingService;
|
||||
private readonly IGraphUserDirectoryService? _graphUserDirectoryService;
|
||||
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||
|
||||
// ── People picker debounce ──────────────────────────────────────────────
|
||||
@@ -74,6 +75,34 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
[ObservableProperty]
|
||||
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 ─────────────────────────────────────────
|
||||
|
||||
/// <summary>Total number of access entries in current results.</summary>
|
||||
@@ -91,17 +120,25 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
? $"{SelectedUsers.Count} user(s) selected"
|
||||
: 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) ─────────────────────────
|
||||
|
||||
/// <summary>ICollectionView over Results supporting grouping and text filtering.</summary>
|
||||
public ICollectionView ResultsView { get; }
|
||||
|
||||
/// <summary>ICollectionView over DirectoryUsers with member/guest and text filtering, sorted by DisplayName.</summary>
|
||||
public ICollectionView DirectoryUsersView { get; }
|
||||
|
||||
// ── Commands ────────────────────────────────────────────────────────────
|
||||
|
||||
public IAsyncRelayCommand ExportCsvCommand { get; }
|
||||
public IAsyncRelayCommand ExportHtmlCommand { get; }
|
||||
public RelayCommand<GraphUserResult> AddUserCommand { get; }
|
||||
public RelayCommand<GraphUserResult> RemoveUserCommand { get; }
|
||||
public IAsyncRelayCommand LoadDirectoryCommand { get; }
|
||||
public RelayCommand CancelDirectoryLoadCommand { get; }
|
||||
|
||||
// ── Current tenant profile ──────────────────────────────────────────────
|
||||
|
||||
@@ -120,6 +157,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
UserAccessCsvExportService csvExportService,
|
||||
UserAccessHtmlExportService htmlExportService,
|
||||
IBrandingService brandingService,
|
||||
IGraphUserDirectoryService graphUserDirectoryService,
|
||||
ILogger<FeatureViewModelBase> logger)
|
||||
: base(logger)
|
||||
{
|
||||
@@ -129,6 +167,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
_csvExportService = csvExportService;
|
||||
_htmlExportService = htmlExportService;
|
||||
_brandingService = brandingService;
|
||||
_graphUserDirectoryService = graphUserDirectoryService;
|
||||
_logger = logger;
|
||||
|
||||
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||
@@ -141,6 +180,17 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
var cvs = new CollectionViewSource { Source = Results };
|
||||
ResultsView = cvs.View;
|
||||
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>
|
||||
@@ -149,7 +199,8 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
IGraphUserSearchService graphUserSearchService,
|
||||
ISessionManager sessionManager,
|
||||
ILogger<FeatureViewModelBase> logger,
|
||||
IBrandingService? brandingService = null)
|
||||
IBrandingService? brandingService = null,
|
||||
IGraphUserDirectoryService? graphUserDirectoryService = null)
|
||||
: base(logger)
|
||||
{
|
||||
_auditService = auditService;
|
||||
@@ -158,6 +209,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
_csvExportService = null;
|
||||
_htmlExportService = null;
|
||||
_brandingService = brandingService;
|
||||
_graphUserDirectoryService = graphUserDirectoryService;
|
||||
_logger = logger;
|
||||
|
||||
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||
@@ -170,6 +222,17 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
var cvs = new CollectionViewSource { Source = Results };
|
||||
ResultsView = cvs.View;
|
||||
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 ─────────────────────────────────
|
||||
@@ -251,6 +314,18 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
NotifySummaryProperties();
|
||||
ExportCsvCommand.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 ─────────────────────────────────
|
||||
@@ -285,6 +360,24 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
/// <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)
|
||||
=> 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 ───────────────────────────────────────────────
|
||||
|
||||
private bool CanExport() => Results.Count > 0;
|
||||
|
||||
Reference in New Issue
Block a user