Files
Sharepoint-Toolbox/.planning/phases/13-user-directory-viewmodel/13-02-PLAN.md
Dev df6f4949a8 docs(13-02): complete User Directory ViewModel plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:44:56 +02:00

22 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
13-user-directory-viewmodel 02 execute 2
13-01
SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs
SharepointToolbox/App.xaml.cs
SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs
true
UDIR-01
UDIR-02
UDIR-03
UDIR-04
truths artifacts key_links
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<GraphDirectoryUser>) 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
path provides contains
SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs Directory browse mode with paginated load, progress, cancellation, filtering, sorting IsBrowseMode
path provides contains
SharepointToolbox/App.xaml.cs DI wiring for IGraphUserDirectoryService into UserAccessAuditViewModel IGraphUserDirectoryService
path provides min_lines
SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs Comprehensive tests for directory browse mode 100
from to via pattern
SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs SharepointToolbox/Services/IGraphUserDirectoryService.cs constructor injection IGraphUserDirectoryService
from to via pattern
SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs SharepointToolbox/Core/Models/GraphDirectoryUser.cs collection element type ObservableCollection<GraphDirectoryUser>
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.

<execution_context> @C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md @C:/Users/dev/.claude/get-shit-done/templates/summary.md </execution_context>

@.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:

public record GraphDirectoryUser(
    string DisplayName, string UserPrincipalName,
    string? Mail, string? Department, string? JobTitle, string? UserType);

From SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs:

// Full constructor (DI):
public UserAccessAuditViewModel(
    IUserAccessAuditService auditService,
    IGraphUserSearchService graphUserSearchService,
    ISessionManager sessionManager,
    UserAccessCsvExportService csvExportService,
    UserAccessHtmlExportService htmlExportService,
    IBrandingService brandingService,
    ILogger<FeatureViewModelBase> logger)

// Test constructor:
internal UserAccessAuditViewModel(
    IUserAccessAuditService auditService,
    IGraphUserSearchService graphUserSearchService,
    ISessionManager sessionManager,
    ILogger<FeatureViewModelBase> 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
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:

private static (UserAccessAuditViewModel vm, Mock<IUserAccessAuditService> auditMock, Mock<IGraphUserSearchService> graphMock)
    CreateViewModel(IReadOnlyList<UserAccessEntry>? auditResult = null)
{
    var mockAudit = new Mock<IUserAccessAuditService>();
    // ... setup
    var vm = new UserAccessAuditViewModel(mockAudit.Object, mockGraph.Object, mockSession.Object,
        NullLogger<FeatureViewModelBase>.Instance);
    vm._currentProfile = new TenantProfile { ... };
    return (vm, mockAudit, mockGraph);
}

From SharepointToolbox/App.xaml.cs:

services.AddTransient<IGraphUserDirectoryService, GraphUserDirectoryService>();
// ...
services.AddTransient<UserAccessAuditViewModel>();
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<FeatureViewModelBase> logger)
   ```
   Assign `_graphUserDirectoryService = graphUserDirectoryService;`

3. Update test constructor — add optional parameter:
   ```csharp
   internal UserAccessAuditViewModel(
       IUserAccessAuditService auditService,
       IGraphUserSearchService graphUserSearchService,
       ISessionManager sessionManager,
       ILogger<FeatureViewModelBase> 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<GraphDirectoryUser> _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<object>().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<int>(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<GraphDirectoryUser> 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<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 { ... };
       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<GraphDirectoryUser>().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).

<success_criteria>

  • 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 </success_criteria>
After completion, create `.planning/phases/13-user-directory-viewmodel/13-02-SUMMARY.md`