docs(13-02): complete User Directory ViewModel plan

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

View File

@@ -0,0 +1,235 @@
---
phase: 13-user-directory-viewmodel
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- SharepointToolbox/Core/Models/GraphDirectoryUser.cs
- SharepointToolbox/Services/IGraphUserDirectoryService.cs
- SharepointToolbox/Services/GraphUserDirectoryService.cs
- SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs
autonomous: true
requirements:
- UDIR-03
must_haves:
truths:
- "GraphDirectoryUser record includes a UserType property (string?) alongside the existing five properties"
- "GraphUserDirectoryService.MapUser populates UserType from the Graph User object"
- "IGraphUserDirectoryService.GetUsersAsync accepts an optional bool includeGuests parameter defaulting to false"
- "When includeGuests is false, the Graph filter remains 'accountEnabled eq true and userType eq Member' (backward compatible)"
- "When includeGuests is true, the Graph filter is 'accountEnabled eq true' (no userType restriction) and userType is in the select set"
- "Existing tests continue to pass with no changes required (default parameter preserves old behavior)"
artifacts:
- path: "SharepointToolbox/Core/Models/GraphDirectoryUser.cs"
provides: "Directory user record with UserType for client-side member/guest filtering"
contains: "UserType"
- path: "SharepointToolbox/Services/IGraphUserDirectoryService.cs"
provides: "Interface with includeGuests parameter"
contains: "includeGuests"
- path: "SharepointToolbox/Services/GraphUserDirectoryService.cs"
provides: "Implementation branching filter based on includeGuests"
contains: "includeGuests"
key_links:
- from: "SharepointToolbox/Services/GraphUserDirectoryService.cs"
to: "SharepointToolbox/Core/Models/GraphDirectoryUser.cs"
via: "MapUser"
pattern: "UserType"
---
<objective>
Extend GraphDirectoryUser with a UserType property and add an includeGuests parameter to GraphUserDirectoryService so that Phase 13-02 can load all users and filter members/guests in-memory.
Purpose: SC3 requires "Members only / Include guests" toggle that filters in-memory without a new Graph request. The service must fetch all users (members + guests) when requested, and the model must carry UserType for client-side filtering.
Output: Updated model, interface, implementation, and tests.
</objective>
<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>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/13-user-directory-viewmodel/13-RESEARCH.md
<interfaces>
<!-- Current GraphDirectoryUser model -->
From SharepointToolbox/Core/Models/GraphDirectoryUser.cs:
```csharp
public record GraphDirectoryUser(
string DisplayName,
string UserPrincipalName,
string? Mail,
string? Department,
string? JobTitle);
```
<!-- Current interface -->
From SharepointToolbox/Services/IGraphUserDirectoryService.cs:
```csharp
public interface IGraphUserDirectoryService
{
Task<IReadOnlyList<GraphDirectoryUser>> GetUsersAsync(
string clientId,
IProgress<int>? progress = null,
CancellationToken ct = default);
}
```
<!-- Current implementation (key parts) -->
From SharepointToolbox/Services/GraphUserDirectoryService.cs:
```csharp
config.QueryParameters.Filter = "accountEnabled eq true and userType eq 'Member'";
config.QueryParameters.Select = new[]
{
"displayName", "userPrincipalName", "mail", "department", "jobTitle"
};
internal static GraphDirectoryUser MapUser(User user) =>
new(
DisplayName: user.DisplayName ?? user.UserPrincipalName ?? string.Empty,
UserPrincipalName: user.UserPrincipalName ?? string.Empty,
Mail: user.Mail,
Department: user.Department,
JobTitle: user.JobTitle);
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add UserType to GraphDirectoryUser</name>
<files>
SharepointToolbox/Core/Models/GraphDirectoryUser.cs
</files>
<behavior>
- GraphDirectoryUser record has 6 positional parameters: DisplayName, UserPrincipalName, Mail, Department, JobTitle, UserType
- UserType is nullable string (string?) — appended as last parameter for backward compat
</behavior>
<action>
1. Edit `SharepointToolbox/Core/Models/GraphDirectoryUser.cs`:
Add `string? UserType` as the last parameter:
```csharp
public record GraphDirectoryUser(
string DisplayName,
string UserPrincipalName,
string? Mail,
string? Department,
string? JobTitle,
string? UserType);
```
2. Check for any existing code that constructs GraphDirectoryUser (MapUser, tests) and add the UserType parameter.
Search for `new GraphDirectoryUser(` and `new(` in test files to find all construction sites.
</action>
<verify>
<automated>dotnet build --no-restore -warnaserror</automated>
</verify>
<done>GraphDirectoryUser has UserType property. All construction sites updated. Build passes.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Add includeGuests parameter to interface and implementation</name>
<files>
SharepointToolbox/Services/IGraphUserDirectoryService.cs,
SharepointToolbox/Services/GraphUserDirectoryService.cs
</files>
<behavior>
- IGraphUserDirectoryService.GetUsersAsync has a new `bool includeGuests = false` parameter
- When includeGuests=false: filter is "accountEnabled eq true and userType eq 'Member'" (unchanged)
- When includeGuests=true: filter is "accountEnabled eq true" (fetches members + guests)
- "userType" is always in the select set (needed for MapUser)
- MapUser includes user.UserType in the mapping
</behavior>
<action>
1. Update `IGraphUserDirectoryService.cs`:
```csharp
Task<IReadOnlyList<GraphDirectoryUser>> GetUsersAsync(
string clientId,
bool includeGuests = false,
IProgress<int>? progress = null,
CancellationToken ct = default);
```
2. Update `GraphUserDirectoryService.cs`:
- Update method signature to match interface
- Add `userType` to Select array
- Branch filter based on includeGuests:
```csharp
config.QueryParameters.Filter = includeGuests
? "accountEnabled eq true"
: "accountEnabled eq true and userType eq 'Member'";
config.QueryParameters.Select = new[]
{
"displayName", "userPrincipalName", "mail", "department", "jobTitle", "userType"
};
```
- Update MapUser:
```csharp
internal static GraphDirectoryUser MapUser(User user) =>
new(
DisplayName: user.DisplayName ?? user.UserPrincipalName ?? string.Empty,
UserPrincipalName: user.UserPrincipalName ?? string.Empty,
Mail: user.Mail,
Department: user.Department,
JobTitle: user.JobTitle,
UserType: user.UserType);
```
</action>
<verify>
<automated>dotnet build --no-restore -warnaserror</automated>
</verify>
<done>Interface and implementation updated. Default parameter preserves backward compat. Build passes.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Update tests</name>
<files>
SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs
</files>
<behavior>
- Existing MapUser tests pass with UserType parameter added
- New test: MapUser populates UserType from User.UserType
- New test: MapUser returns null UserType when User.UserType is null
</behavior>
<action>
1. Read `SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs`
2. Update any existing `MapUser` test assertions to include the UserType field
3. Add test: MapUser_PopulatesUserType — set User.UserType = "Member", verify GraphDirectoryUser.UserType == "Member"
4. Add test: MapUser_NullUserType — set User.UserType = null, verify GraphDirectoryUser.UserType is null
5. Run tests
</action>
<verify>
<automated>dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~GraphUserDirectoryService" --no-build -q</automated>
</verify>
<done>All MapUser tests pass including UserType coverage.</done>
</task>
</tasks>
<verification>
```bash
dotnet build --no-restore -warnaserror
dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~GraphUserDirectoryService" --no-build -q
```
Both must pass with zero failures.
</verification>
<success_criteria>
- GraphDirectoryUser has UserType (string?) as last positional parameter
- IGraphUserDirectoryService.GetUsersAsync has bool includeGuests = false parameter
- When includeGuests=false, filter unchanged (backward compatible)
- When includeGuests=true, filter omits userType restriction
- MapUser populates UserType from Graph User object
- userType always in select set
- All tests pass
</success_criteria>
<output>
After completion, create `.planning/phases/13-user-directory-viewmodel/13-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,529 @@
---
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<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"
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<GraphDirectoryUser>"
---
<objective>
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.
</objective>
<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>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/13-user-directory-viewmodel/13-RESEARCH.md
<interfaces>
<!-- IGraphUserDirectoryService (after 13-01) -->
From SharepointToolbox/Services/IGraphUserDirectoryService.cs:
```csharp
public interface IGraphUserDirectoryService
{
Task<IReadOnlyList<GraphDirectoryUser>> GetUsersAsync(
string clientId,
bool includeGuests = false,
IProgress<int>? progress = null,
CancellationToken ct = default);
}
```
<!-- GraphDirectoryUser (after 13-01) -->
From SharepointToolbox/Core/Models/GraphDirectoryUser.cs:
```csharp
public record GraphDirectoryUser(
string DisplayName, string UserPrincipalName,
string? Mail, string? Department, string? JobTitle, string? UserType);
```
<!-- Current ViewModel constructors -->
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<FeatureViewModelBase> logger)
// Test constructor:
internal UserAccessAuditViewModel(
IUserAccessAuditService auditService,
IGraphUserSearchService graphUserSearchService,
ISessionManager sessionManager,
ILogger<FeatureViewModelBase> logger,
IBrandingService? brandingService = null)
```
<!-- FeatureViewModelBase patterns -->
From SharepointToolbox/ViewModels/FeatureViewModelBase.cs:
- IsRunning, StatusMessage, ProgressValue
- RunCommand / CancelCommand (uses own CTS)
- Protected abstract RunOperationAsync(ct, progress)
- OnTenantSwitched(profile) virtual override
<!-- Existing CollectionView pattern in same ViewModel -->
```csharp
var cvs = new CollectionViewSource { Source = Results };
ResultsView = cvs.View;
ResultsView.GroupDescriptions.Add(new PropertyGroupDescription(...));
ResultsView.Filter = FilterPredicate;
// On change: ResultsView.Refresh();
```
<!-- Existing test helper pattern -->
From SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelTests.cs:
```csharp
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);
}
```
<!-- DI registration pattern -->
From SharepointToolbox/App.xaml.cs:
```csharp
services.AddTransient<IGraphUserDirectoryService, GraphUserDirectoryService>();
// ...
services.AddTransient<UserAccessAuditViewModel>();
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add IGraphUserDirectoryService to ViewModel constructors and DI</name>
<files>
SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs,
SharepointToolbox/App.xaml.cs
</files>
<behavior>
- 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
</behavior>
<action>
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.
</action>
<verify>
<automated>dotnet build --no-restore -warnaserror</automated>
</verify>
<done>IGraphUserDirectoryService injected into ViewModel. DI resolves it automatically. Build passes.</done>
</task>
<task type="auto">
<name>Task 2: Add directory browse mode properties and commands</name>
<files>
SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs
</files>
<behavior>
- 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)
</behavior>
<action>
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();
}
```
</action>
<verify>
<automated>dotnet build --no-restore -warnaserror</automated>
</verify>
<done>All directory browse properties, commands, and change handlers exist. Build passes.</done>
</task>
<task type="auto">
<name>Task 3: Implement LoadDirectoryAsync, CancelDirectoryLoad, and filter predicate</name>
<files>
SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs
</files>
<behavior>
- 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
</behavior>
<action>
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));
```
</action>
<verify>
<automated>dotnet build --no-restore -warnaserror</automated>
</verify>
<done>LoadDirectoryAsync, filter predicate, and tenant switch cleanup implemented. Build passes.</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: Write comprehensive tests for directory browse mode</name>
<files>
SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs
</files>
<behavior>
- 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)
</behavior>
<action>
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().
</action>
<verify>
<automated>dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~UserAccessAuditViewModelDirectory" --no-build -q</automated>
</verify>
<done>15+ tests covering all directory browse mode behavior. All pass.</done>
</task>
</tasks>
<verification>
```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).
</verification>
<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>
<output>
After completion, create `.planning/phases/13-user-directory-viewmodel/13-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,92 @@
---
phase: 13-user-directory-viewmodel
plan: 02
subsystem: viewmodel
tags: [wpf, mvvm, user-directory, icollectionview, csharp]
requires:
- phase: 13-user-directory-viewmodel
plan: 01
provides: IGraphUserDirectoryService with includeGuests param, GraphDirectoryUser with UserType
provides:
- Directory browse mode in UserAccessAuditViewModel with load, filter, sort, cancel
- ICollectionView for directory users with member/guest and text filtering
- 16 unit tests for directory browse behavior
affects:
- SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs
- SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs
tech-stack:
added: []
patterns:
- ICollectionView with SortDescription and Filter predicate for directory users
- Separate CancellationTokenSource for directory load (independent from base class CTS)
- Optional constructor parameter for testability (IGraphUserDirectoryService?)
key-files:
created:
- SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs
modified:
- SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs
key-decisions:
- IGraphUserDirectoryService injected as optional param in test constructor to preserve backward compat
- Directory always fetches with includeGuests=true from Graph; member/guest filtering is in-memory via ICollectionView
- Separate _directoryCts field for directory load cancellation (not sharing base class _cts)
- No App.xaml.cs change needed — DI auto-resolves IGraphUserDirectoryService for UserAccessAuditViewModel
metrics:
duration: 261s
completed: "2026-04-08T14:08:05Z"
tasks_completed: 4
tasks_total: 4
tests_added: 16
tests_passing: 24
files_changed: 2
---
# Phase 13 Plan 02: User Directory ViewModel Summary
Directory browse mode with paginated Graph load, member/guest toggle filter, text search across 4 fields, and DisplayName-sorted ICollectionView -- all testable without WPF View layer.
## What Was Done
### Task 1: Inject IGraphUserDirectoryService into ViewModel
- Added `_graphUserDirectoryService` field to `UserAccessAuditViewModel`
- Added required parameter to full (DI) constructor after `brandingService`
- Added optional parameter to test constructor for backward compatibility
- Verified DI auto-resolves via existing `services.AddTransient<UserAccessAuditViewModel>()` registration
### Task 2: Add directory browse mode properties and commands
- Added 6 observable properties: `IsBrowseMode`, `DirectoryUsers`, `IsLoadingDirectory`, `DirectoryLoadStatus`, `IncludeGuests`, `DirectoryFilterText`
- Added `DirectoryUserCount` computed property reflecting filtered view count
- Added `DirectoryUsersView` (ICollectionView) with default SortDescription on DisplayName ascending
- Added `LoadDirectoryCommand` (IAsyncRelayCommand) and `CancelDirectoryLoadCommand` (RelayCommand)
- Initialized CollectionView and commands in both constructors
- Added change handlers: `OnIncludeGuestsChanged`, `OnDirectoryFilterTextChanged`, `OnIsLoadingDirectoryChanged`
### Task 3: Implement LoadDirectoryAsync, filter predicate, tenant switch cleanup
- `LoadDirectoryAsync`: validates service/profile, creates CTS, calls GetUsersAsync with progress reporting, populates on UI thread, handles cancel/error
- `DirectoryFilterPredicate`: filters by IncludeGuests (UserType=="Member") then by text match on DisplayName, UPN, Department, JobTitle
- `PopulateDirectory` helper: clears and repopulates collection, refreshes view
- `OnTenantSwitched`: cancels directory CTS, clears DirectoryUsers, resets all directory state
- Exposed `TestLoadDirectoryAsync()` internal method for test access
### Task 4: Write comprehensive tests (16 tests)
- Created `UserAccessAuditViewModelDirectoryTests.cs` with helper factories
- Tests cover: defaults, load populates, progress status, no-profile guard, cancellation, member/guest filtering, text filtering (DisplayName, Department, JobTitle), sort order, tenant switch reset, filtered count, search mode regression
## Deviations from Plan
None -- plan executed exactly as written.
## Verification
- `dotnet build --no-restore -warnaserror`: PASSED (0 warnings, 0 errors)
- `dotnet test --filter "FullyQualifiedName~UserAccessAuditViewModel"`: 24/24 PASSED (8 existing + 16 new)
## Commits
| Hash | Message |
|------|---------|
| 4ba4de6 | feat(13-02): add directory browse mode with paginated load, member/guest filter, and sortable ICollectionView |

View File

@@ -0,0 +1,73 @@
# Phase 13 Research: User Directory ViewModel
## What Exists
### GraphUserDirectoryService (Phase 10)
- `GetUsersAsync(clientId, progress?, ct)``IReadOnlyList<GraphDirectoryUser>`
- Filter: `accountEnabled eq true and userType eq 'Member'` (members only)
- Select: displayName, userPrincipalName, mail, department, jobTitle
- Uses `PageIterator<User, UserCollectionResponse>` for transparent pagination
- Reports progress via `IProgress<int>` (running count)
- Honors cancellation in page callback
### GraphDirectoryUser Model
```csharp
public record GraphDirectoryUser(
string DisplayName, string UserPrincipalName,
string? Mail, string? Department, string? JobTitle);
```
**GAP**: No `UserType` property — needed for SC3 member/guest in-memory filtering.
### UserAccessAuditViewModel (Phase 7)
- Inherits `FeatureViewModelBase` (IsRunning, StatusMessage, ProgressValue, RunCommand, CancelCommand)
- People-picker search: `SearchQuery` → debounce → `IGraphUserSearchService.SearchUsersAsync``SearchResults`
- User selection: `SelectedUsers` (ObservableCollection<GraphUserResult>) → `RunOperationAsync` → audit
- Results: `Results` (ObservableCollection<UserAccessEntry>) + `ResultsView` (ICollectionView with grouping/filtering)
- Two constructors: full (DI) and test (omits export services)
- `_currentProfile` tracks active tenant (via TenantSwitchedMessage)
- `OnTenantSwitched` clears all state
### ICollectionView Pattern (existing in same ViewModel)
```csharp
var cvs = new CollectionViewSource { Source = Results };
ResultsView = cvs.View;
ResultsView.GroupDescriptions.Add(new PropertyGroupDescription(...));
ResultsView.Filter = FilterPredicate;
// On filter change: ResultsView.Refresh();
```
### DI Registration
- `IGraphUserDirectoryService` registered as Transient
- `UserAccessAuditViewModel` registered as Transient
- Currently NOT injected into UserAccessAuditViewModel
## Gaps to Fill
1. **GraphDirectoryUser needs UserType** — add `string? UserType` to record + update MapUser + select
2. **Service needs guest inclusion** — add `bool includeGuests` parameter; when true, drop userType filter
3. **ViewModel needs IGraphUserDirectoryService** — add to both constructors
4. **ViewModel needs browse mode** — mode toggle, directory collection, load command, cancel, filter, sort
5. **DI registration** — add IGraphUserDirectoryService to UserAccessAuditViewModel constructor resolution
## Plan Breakdown
1. **13-01** (Wave 1): Extend GraphDirectoryUser + GraphUserDirectoryService
- Add UserType to model
- Add userType to select fields
- Add `includeGuests` parameter (default false for backward compat)
- Update MapUser
- Update tests
2. **13-02** (Wave 2): UserAccessAuditViewModel directory browse mode
- Inject IGraphUserDirectoryService
- Add AuditMode enum (Search/Browse) + IsBrowseMode toggle
- Add DirectoryUsers collection + DirectoryUsersView (ICollectionView)
- Add LoadDirectoryCommand with own CTS, progress reporting
- Add CancelDirectoryLoadCommand
- Add IncludeGuests toggle + in-memory filter by UserType
- Add DirectoryFilterText + filter predicate (DisplayName, UPN, Department, JobTitle)
- Add SortDescription defaults (DisplayName ascending)
- Add DirectoryLoadStatus string for "Loading... X users" display
- Update OnTenantSwitched to clear directory state
- Update DI in App.xaml.cs
- Comprehensive tests