--- phase: 13-user-directory-viewmodel plan: 02 type: execute wave: 2 depends_on: [13-01] files_modified: - SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs - SharepointToolbox/App.xaml.cs - SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs autonomous: true requirements: - UDIR-01 - UDIR-02 - UDIR-03 - UDIR-04 must_haves: truths: - "UserAccessAuditViewModel exposes an IsBrowseMode bool toggle property that switches between Search and Browse modes" - "When IsBrowseMode is false (default), all existing people-picker behavior works identically (no regression)" - "LoadDirectoryCommand calls IGraphUserDirectoryService.GetUsersAsync with includeGuests=true, reports progress via DirectoryLoadStatus, supports cancellation via CancelDirectoryLoadCommand" - "DirectoryUsers (ObservableCollection) is populated after load completes" - "DirectoryUsersView (ICollectionView) wraps DirectoryUsers with filtering by IncludeGuests toggle and DirectoryFilterText, and default SortDescription on DisplayName" - "IncludeGuests toggle filters DirectoryUsersView in-memory by UserType without issuing a new Graph request" - "DirectoryFilterText filters by DisplayName, UserPrincipalName, Department, and JobTitle" - "Each user row in DirectoryUsersView exposes DisplayName, UserPrincipalName, Department, and JobTitle (via GraphDirectoryUser properties)" - "IsLoadingDirectory is true while directory load is in progress, false otherwise" - "CancelDirectoryLoadCommand cancels the in-flight directory load and sets IsLoadingDirectory to false" - "OnTenantSwitched clears directory state (DirectoryUsers, DirectoryFilterText, IsBrowseMode)" - "IGraphUserDirectoryService is injected via constructor and registered in DI" artifacts: - path: "SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs" provides: "Directory browse mode with paginated load, progress, cancellation, filtering, sorting" contains: "IsBrowseMode" - path: "SharepointToolbox/App.xaml.cs" provides: "DI wiring for IGraphUserDirectoryService into UserAccessAuditViewModel" contains: "IGraphUserDirectoryService" - path: "SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs" provides: "Comprehensive tests for directory browse mode" min_lines: 100 key_links: - from: "SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs" to: "SharepointToolbox/Services/IGraphUserDirectoryService.cs" via: "constructor injection" pattern: "IGraphUserDirectoryService" - from: "SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs" to: "SharepointToolbox/Core/Models/GraphDirectoryUser.cs" via: "collection element type" pattern: "ObservableCollection" --- Add directory browse mode to UserAccessAuditViewModel with paginated load, progress, cancellation, member/guest filtering, text search, and sorting — all fully testable without the View. Purpose: Implements SC1-SC4 for Phase 13. Administrators get a toggle between the existing people-picker search and a new directory browse mode that loads all tenant users, supports member/guest filtering, and displays Department/JobTitle columns. Output: Updated ViewModel with directory browse mode, DI registration, and comprehensive test coverage. @C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md @C:/Users/dev/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/13-user-directory-viewmodel/13-RESEARCH.md From SharepointToolbox/Services/IGraphUserDirectoryService.cs: ```csharp public interface IGraphUserDirectoryService { Task> GetUsersAsync( string clientId, bool includeGuests = false, IProgress? progress = null, CancellationToken ct = default); } ``` From SharepointToolbox/Core/Models/GraphDirectoryUser.cs: ```csharp public record GraphDirectoryUser( string DisplayName, string UserPrincipalName, string? Mail, string? Department, string? JobTitle, string? UserType); ``` From SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs: ```csharp // Full constructor (DI): public UserAccessAuditViewModel( IUserAccessAuditService auditService, IGraphUserSearchService graphUserSearchService, ISessionManager sessionManager, UserAccessCsvExportService csvExportService, UserAccessHtmlExportService htmlExportService, IBrandingService brandingService, ILogger logger) // Test constructor: internal UserAccessAuditViewModel( IUserAccessAuditService auditService, IGraphUserSearchService graphUserSearchService, ISessionManager sessionManager, ILogger logger, IBrandingService? brandingService = null) ``` From SharepointToolbox/ViewModels/FeatureViewModelBase.cs: - IsRunning, StatusMessage, ProgressValue - RunCommand / CancelCommand (uses own CTS) - Protected abstract RunOperationAsync(ct, progress) - OnTenantSwitched(profile) virtual override ```csharp var cvs = new CollectionViewSource { Source = Results }; ResultsView = cvs.View; ResultsView.GroupDescriptions.Add(new PropertyGroupDescription(...)); ResultsView.Filter = FilterPredicate; // On change: ResultsView.Refresh(); ``` From SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelTests.cs: ```csharp private static (UserAccessAuditViewModel vm, Mock auditMock, Mock graphMock) CreateViewModel(IReadOnlyList? auditResult = null) { var mockAudit = new Mock(); // ... setup var vm = new UserAccessAuditViewModel(mockAudit.Object, mockGraph.Object, mockSession.Object, NullLogger.Instance); vm._currentProfile = new TenantProfile { ... }; return (vm, mockAudit, mockGraph); } ``` From SharepointToolbox/App.xaml.cs: ```csharp services.AddTransient(); // ... services.AddTransient(); ``` Task 1: Add IGraphUserDirectoryService to ViewModel constructors and DI SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs, SharepointToolbox/App.xaml.cs - Full constructor accepts IGraphUserDirectoryService as a parameter - Test constructor accepts IGraphUserDirectoryService? as optional parameter - Field _graphUserDirectoryService stores the injected service - App.xaml.cs DI resolves IGraphUserDirectoryService for UserAccessAuditViewModel 1. Add field to ViewModel: ```csharp private readonly IGraphUserDirectoryService? _graphUserDirectoryService; ``` 2. Update full constructor — add `IGraphUserDirectoryService graphUserDirectoryService` parameter after `brandingService`: ```csharp public UserAccessAuditViewModel( IUserAccessAuditService auditService, IGraphUserSearchService graphUserSearchService, ISessionManager sessionManager, UserAccessCsvExportService csvExportService, UserAccessHtmlExportService htmlExportService, IBrandingService brandingService, IGraphUserDirectoryService graphUserDirectoryService, ILogger logger) ``` Assign `_graphUserDirectoryService = graphUserDirectoryService;` 3. Update test constructor — add optional parameter: ```csharp internal UserAccessAuditViewModel( IUserAccessAuditService auditService, IGraphUserSearchService graphUserSearchService, ISessionManager sessionManager, ILogger logger, IBrandingService? brandingService = null, IGraphUserDirectoryService? graphUserDirectoryService = null) ``` Assign `_graphUserDirectoryService = graphUserDirectoryService;` 4. In App.xaml.cs, the existing DI registration for `UserAccessAuditViewModel` is Transient and uses constructor injection — since `IGraphUserDirectoryService` is already registered as Transient, DI auto-resolves it. No change needed in App.xaml.cs unless the constructor parameter order requires explicit factory. Verify by building. dotnet build --no-restore -warnaserror IGraphUserDirectoryService injected into ViewModel. DI resolves it automatically. Build passes. Task 2: Add directory browse mode properties and commands SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs - IsBrowseMode (bool) toggle property, default false - DirectoryUsers (ObservableCollection of GraphDirectoryUser) - DirectoryUsersView (ICollectionView) with filter and default sort on DisplayName - IsLoadingDirectory (bool) loading indicator - DirectoryLoadStatus (string) for "Loading... X users" display - IncludeGuests (bool) toggle for member/guest filtering - DirectoryFilterText (string) for text search - DirectoryUserCount (int) computed property showing filtered count - LoadDirectoryCommand (IAsyncRelayCommand) - CancelDirectoryLoadCommand (RelayCommand) - Own CancellationTokenSource for directory load (separate from base class CTS) 1. Add observable properties: ```csharp [ObservableProperty] private bool _isBrowseMode; [ObservableProperty] private ObservableCollection _directoryUsers = new(); [ObservableProperty] private bool _isLoadingDirectory; [ObservableProperty] private string _directoryLoadStatus = string.Empty; [ObservableProperty] private bool _includeGuests; [ObservableProperty] private string _directoryFilterText = string.Empty; ``` 2. Add computed property: ```csharp public int DirectoryUserCount => DirectoryUsersView?.Cast().Count() ?? 0; ``` 3. Add ICollectionView + CTS: ```csharp public ICollectionView DirectoryUsersView { get; } private CancellationTokenSource? _directoryCts; ``` 4. Add commands: ```csharp public IAsyncRelayCommand LoadDirectoryCommand { get; } public RelayCommand CancelDirectoryLoadCommand { get; } ``` 5. Initialize in BOTH constructors (after existing init): ```csharp 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); ``` 6. Add change handlers: ```csharp 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(); } ``` dotnet build --no-restore -warnaserror All directory browse properties, commands, and change handlers exist. Build passes. Task 3: Implement LoadDirectoryAsync, CancelDirectoryLoad, and filter predicate SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs - LoadDirectoryAsync fetches all users via IGraphUserDirectoryService.GetUsersAsync(clientId, includeGuests: true) - Reports progress via DirectoryLoadStatus = $"Loading... {count} users" - Populates DirectoryUsers on UI thread - Sets IsLoadingDirectory true/false around the operation - Handles cancellation (OperationCanceledException → sets status message) - Handles errors (Exception → sets status message, logs) - CancelDirectoryLoad cancels _directoryCts - DirectoryFilterPredicate filters by DisplayName, UPN, Department, JobTitle (case-insensitive contains) - When IncludeGuests is false, only shows users where UserType == "Member" (or UserType is null — defensive) - When IncludeGuests is true, shows all users - OnTenantSwitched clears DirectoryUsers, DirectoryFilterText, resets IsBrowseMode to false 1. Implement LoadDirectoryAsync: ```csharp 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(count => DirectoryLoadStatus = $"Loading... {count} users"); var users = await _graphUserDirectoryService.GetUsersAsync( clientId, includeGuests: true, progress, ct); ct.ThrowIfCancellationRequested(); var dispatcher = System.Windows.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 users) { DirectoryUsers.Clear(); foreach (var u in users) DirectoryUsers.Add(u); DirectoryUsersView.Refresh(); OnPropertyChanged(nameof(DirectoryUserCount)); } ``` 2. Implement DirectoryFilterPredicate: ```csharp 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); } ``` NOTE: When IncludeGuests is false, show users where UserType is "Member". Users with null UserType are excluded (defensive — should not happen with the updated select). 3. Update OnTenantSwitched — add directory state reset after existing code: ```csharp // 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)); ``` dotnet build --no-restore -warnaserror LoadDirectoryAsync, filter predicate, and tenant switch cleanup implemented. Build passes. Task 4: Write comprehensive tests for directory browse mode SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs - Test 1: IsBrowseMode defaults to false - Test 2: DirectoryUsers is empty by default - Test 3: LoadDirectoryCommand exists and is not null - Test 4: LoadDirectoryAsync populates DirectoryUsers with results from service - Test 5: LoadDirectoryAsync reports progress via DirectoryLoadStatus - Test 6: LoadDirectoryAsync with no profile sets StatusMessage and returns - Test 7: CancelDirectoryLoadCommand cancels in-flight load - Test 8: IncludeGuests=false filters out non-Member users in DirectoryUsersView - Test 9: IncludeGuests=true shows all users in DirectoryUsersView - Test 10: DirectoryFilterText filters by DisplayName - Test 11: DirectoryFilterText filters by Department - Test 12: DirectoryUsersView default sort is DisplayName ascending - Test 13: OnTenantSwitched clears DirectoryUsers and resets IsBrowseMode - Test 14: DirectoryUserCount reflects filtered count - Test 15: Search mode properties (SearchQuery, SelectedUsers) still work (no regression) 1. Create `SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs` 2. Create helper factory similar to existing tests but also including IGraphUserDirectoryService mock: ```csharp private static (UserAccessAuditViewModel vm, Mock dirMock, Mock auditMock) CreateViewModel(IReadOnlyList? directoryResult = null) { var mockAudit = new Mock(); var mockGraph = new Mock(); var mockSession = new Mock(); var mockDir = new Mock(); mockDir.Setup(s => s.GetUsersAsync( It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync(directoryResult ?? Array.Empty()); var vm = new UserAccessAuditViewModel( mockAudit.Object, mockGraph.Object, mockSession.Object, NullLogger.Instance, graphUserDirectoryService: mockDir.Object); vm._currentProfile = new TenantProfile { ... }; return (vm, mockDir, mockAudit); } ``` 3. Create test data helpers: ```csharp private static GraphDirectoryUser MakeMember(string name = "Alice", string dept = "IT") => new(name, $"{name.ToLower()}@contoso.com", null, dept, "Engineer", "Member"); private static GraphDirectoryUser MakeGuest(string name = "Bob External") => new(name, $"{name.ToLower().Replace(" ", "")}@external.com", null, null, null, "Guest"); ``` 4. Write all tests. Use `[Trait("Category", "Unit")]`. For LoadDirectoryAsync test: call the command via `vm.LoadDirectoryCommand.ExecuteAsync(null)` or expose an internal test method. For ICollectionView filtering tests: add users to DirectoryUsers, set IncludeGuests/DirectoryFilterText, then check DirectoryUsersView.Cast().Count(). dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~UserAccessAuditViewModelDirectory" --no-build -q 15+ tests covering all directory browse mode behavior. All pass. ```bash dotnet build --no-restore -warnaserror dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~UserAccessAuditViewModel" --no-build -q ``` Both must pass. Existing UserAccessAuditViewModelTests must still pass (no regression). - SC1: IsBrowseMode toggle switches between Search and Browse modes; default is Search; no regression - SC2: LoadDirectoryCommand fetches all users with progress reporting and cancellation support - SC3: IncludeGuests toggle filters DirectoryUsersView in-memory without new Graph request - SC4: DirectoryUsersView exposes DisplayName, UPN, Department, JobTitle; sorted by DisplayName - IGraphUserDirectoryService injected via DI - OnTenantSwitched clears all directory state - 15+ tests covering all behaviors - Build passes with zero warnings After completion, create `.planning/phases/13-user-directory-viewmodel/13-02-SUMMARY.md`