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:
@@ -18,10 +18,10 @@ Requirements for v2.2 Report Branding & User Directory. Each maps to roadmap pha
|
||||
|
||||
### User Directory
|
||||
|
||||
- [ ] **UDIR-01**: User can toggle between search mode and directory browse mode in user access audit tab
|
||||
- [ ] **UDIR-02**: User can browse full tenant user directory with pagination (handles 999+ users)
|
||||
- [x] **UDIR-01**: User can toggle between search mode and directory browse mode in user access audit tab
|
||||
- [x] **UDIR-02**: User can browse full tenant user directory with pagination (handles 999+ users)
|
||||
- [x] **UDIR-03**: User can filter directory by user type (member vs guest)
|
||||
- [ ] **UDIR-04**: User can see department and job title columns in directory list
|
||||
- [x] **UDIR-04**: User can see department and job title columns in directory list
|
||||
- [ ] **UDIR-05**: User can select one or more users from directory to run the access audit
|
||||
|
||||
## Future Requirements
|
||||
@@ -57,10 +57,10 @@ Which phases cover which requirements. Updated during roadmap creation.
|
||||
| BRAND-05 | Phase 11 | Complete |
|
||||
| BRAND-04 | Phase 11 | Complete |
|
||||
| BRAND-02 | Phase 12 | Complete |
|
||||
| UDIR-01 | Phase 13 | Pending |
|
||||
| UDIR-02 | Phase 13 | Pending |
|
||||
| UDIR-01 | Phase 13 | Complete |
|
||||
| UDIR-02 | Phase 13 | Complete |
|
||||
| UDIR-03 | Phase 13 | Complete |
|
||||
| UDIR-04 | Phase 13 | Pending |
|
||||
| UDIR-04 | Phase 13 | Complete |
|
||||
| UDIR-05 | Phase 14 | Pending |
|
||||
|
||||
**Coverage:**
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
- [x] **Phase 10: Branding Data Foundation** — Models, repository, and services for logo storage and user directory enumeration (completed 2026-04-08)
|
||||
- [x] **Phase 11: HTML Export Branding + ViewModel Integration** — Inject logos into all 5 HTML report types; wire branding into export-triggering ViewModels and logo management commands (completed 2026-04-08)
|
||||
- [x] **Phase 12: Branding UI Views** — Settings and profile dialog logo sections with live preview; auto-pull client logo from Entra branding API (completed 2026-04-08)
|
||||
- [ ] **Phase 13: User Directory ViewModel** — Browse mode state, paginated directory load, member/guest filter, and department/job title columns
|
||||
- [x] **Phase 13: User Directory ViewModel** — Browse mode state, paginated directory load, member/guest filter, and department/job title columns (completed 2026-04-08)
|
||||
- [ ] **Phase 14: User Directory View** — Toggle panel in UserAccessAuditView, user selection to trigger existing audit pipeline
|
||||
|
||||
## Phase Details
|
||||
@@ -97,8 +97,8 @@ Plans:
|
||||
4. Each user row in the observable collection exposes DisplayName, UPN, Department, and JobTitle; Department and JobTitle columns are visible and sortable in the ViewModel's `ICollectionView`
|
||||
**Plans**: 2 plans
|
||||
Plans:
|
||||
- [ ] 13-01-PLAN.md — Extend GraphDirectoryUser with UserType + service includeGuests parameter
|
||||
- [ ] 13-02-PLAN.md — UserAccessAuditViewModel directory browse mode (toggle, load, filter, sort, tests)
|
||||
- [x] 13-01-PLAN.md — Extend GraphDirectoryUser with UserType + service includeGuests parameter
|
||||
- [x] 13-02-PLAN.md — UserAccessAuditViewModel directory browse mode (toggle, load, filter, sort, tests)
|
||||
|
||||
### Phase 14: User Directory View
|
||||
**Goal**: Administrators can toggle into directory browse mode from the user access audit tab, see the paginated user list with filters, and launch an access audit for a selected user.
|
||||
@@ -120,5 +120,5 @@ Plans:
|
||||
| 10. Branding Data Foundation | v2.2 | 3/3 | Complete | 2026-04-08 |
|
||||
| 11. HTML Export Branding + ViewModel Integration | 4/4 | Complete | 2026-04-08 | — |
|
||||
| 12. Branding UI Views | 3/3 | Complete | 2026-04-08 | — |
|
||||
| 13. User Directory ViewModel | 1/2 | In Progress| | — |
|
||||
| 13. User Directory ViewModel | 2/2 | Complete | 2026-04-08 | — |
|
||||
| 14. User Directory View | v2.2 | 0/? | Not started | — |
|
||||
|
||||
@@ -3,14 +3,14 @@ gsd_state_version: 1.0
|
||||
milestone: v2.2
|
||||
milestone_name: Report Branding & User Directory
|
||||
status: completed
|
||||
stopped_at: Completed 13-01-PLAN.md
|
||||
last_updated: "2026-04-08T14:02:35.700Z"
|
||||
stopped_at: Completed 13-02-PLAN.md
|
||||
last_updated: "2026-04-08T14:08:49.579Z"
|
||||
last_activity: 2026-04-08 — Phase 11 planning completed
|
||||
progress:
|
||||
total_phases: 5
|
||||
completed_phases: 3
|
||||
completed_phases: 4
|
||||
total_plans: 12
|
||||
completed_plans: 11
|
||||
completed_plans: 12
|
||||
---
|
||||
|
||||
# Project State
|
||||
@@ -69,6 +69,8 @@ Decisions are logged in PROJECT.md Key Decisions table.
|
||||
- [Phase 12]: Used Grid overlay with DataTrigger for logo/placeholder visibility toggle in SettingsView
|
||||
- [Phase 12]: Label+StackPanel layout for logo section in ProfileManagementDialog, consistent with SettingsView pattern
|
||||
- [Phase 13]: UserType added as last positional param for backward compat; includeGuests defaults false; userType always in Select
|
||||
- [Phase 13]: Directory always fetches with includeGuests=true from Graph; member/guest filtering is in-memory via ICollectionView
|
||||
- [Phase 13]: Separate _directoryCts for directory load cancellation (independent from base class _cts)
|
||||
|
||||
### Pending Todos
|
||||
|
||||
@@ -83,7 +85,7 @@ None.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-04-08T14:02:35.697Z
|
||||
Stopped at: Completed 13-01-PLAN.md
|
||||
Last session: 2026-04-08T14:08:49.577Z
|
||||
Stopped at: Completed 13-02-PLAN.md
|
||||
Resume file: None
|
||||
Next step: `/gsd:execute-phase 11`
|
||||
|
||||
@@ -107,7 +107,7 @@ internal static class BrandingHtmlHelper
|
||||
HtmlExportService.cs:
|
||||
```csharp
|
||||
public string BuildHtml(IReadOnlyList<PermissionEntry> entries)
|
||||
public string BuildSimplifiedHtml(IReadOnlyList<SimplifiedPermissionEntry> entries)
|
||||
public string BuildHtml(IReadOnlyList<SimplifiedPermissionEntry> entries)
|
||||
public async Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct)
|
||||
public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct)
|
||||
```
|
||||
@@ -160,7 +160,7 @@ public async Task WriteAsync(IReadOnlyList<UserAccessEntry> entries, string file
|
||||
|
||||
For `HtmlExportService.cs`:
|
||||
- `BuildHtml(IReadOnlyList<PermissionEntry> entries, ReportBranding? branding = null)`
|
||||
- `BuildSimplifiedHtml(IReadOnlyList<SimplifiedPermissionEntry> entries, ReportBranding? branding = null)`
|
||||
- `BuildHtml(IReadOnlyList<SimplifiedPermissionEntry> entries, ReportBranding? branding = null)` (second overload of BuildHtml — NOT a separate method)
|
||||
- In both methods, find the line `sb.AppendLine("<body>");` followed by `sb.AppendLine("<h1>...")`
|
||||
- Insert `sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));` AFTER the `<body>` line and BEFORE the `<h1>` line
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ files_modified:
|
||||
- SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs
|
||||
- SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs
|
||||
- SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs
|
||||
- SharepointToolbox/App.xaml.cs
|
||||
# Note: App.xaml.cs does NOT need changes — DI container auto-resolves IBrandingService for ViewModel constructors
|
||||
autonomous: true
|
||||
requirements:
|
||||
- BRAND-05
|
||||
@@ -103,7 +103,8 @@ Each ViewModel has:
|
||||
|
||||
PermissionsViewModel has two constructors: full (DI) and test (internal, omits export services).
|
||||
UserAccessAuditViewModel also has two constructors.
|
||||
The other 3 ViewModels (Search, Storage, Duplicates) have a single constructor each.
|
||||
StorageViewModel also has two constructors (test constructor at line 151).
|
||||
The other 2 ViewModels (Search, Duplicates) have a single constructor each.
|
||||
|
||||
DI registrations in App.xaml.cs:
|
||||
```csharp
|
||||
@@ -136,7 +137,7 @@ But each ViewModel registration must now resolve IBrandingService in addition to
|
||||
|
||||
3. Modify the DI constructor to accept `IBrandingService brandingService` parameter and assign `_brandingService = brandingService;`.
|
||||
|
||||
4. For ViewModels with a test constructor (PermissionsViewModel, UserAccessAuditViewModel): add `IBrandingService? brandingService = null` as the last parameter, assign `_brandingService = brandingService!;`. Using `null!` is acceptable because test constructors are only used in tests where branding is not exercised. Alternatively, create a no-op implementation — but `null!` matches existing pattern where `_htmlExportService = null` is already used in test constructors.
|
||||
4. For ViewModels with a test constructor (PermissionsViewModel, UserAccessAuditViewModel, StorageViewModel): add `IBrandingService? brandingService = null` as the last parameter, assign `_brandingService = brandingService!;`. Using `null!` is acceptable because test constructors are only used in tests where branding is not exercised. Alternatively, create a no-op implementation — but `null!` matches existing pattern where `_htmlExportService = null` is already used in test constructors. **Verify that existing test files for all 3 ViewModels still compile after the constructor changes.**
|
||||
|
||||
5. Modify `ExportHtmlAsync()` — add branding assembly BEFORE the WriteAsync call:
|
||||
```csharp
|
||||
|
||||
123
.planning/phases/11-html-export-branding/11-CONTEXT.md
Normal file
123
.planning/phases/11-html-export-branding/11-CONTEXT.md
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
phase: 11
|
||||
title: HTML Export Branding + ViewModel Integration
|
||||
status: ready-for-planning
|
||||
created: 2026-04-08
|
||||
---
|
||||
|
||||
# Phase 11 Context: HTML Export Branding + ViewModel Integration
|
||||
|
||||
## Decided Areas (from Phase 10 context + STATE.md)
|
||||
|
||||
These are locked — do not re-litigate during planning or execution.
|
||||
|
||||
| Decision | Value |
|
||||
|---|---|
|
||||
| Logo storage format | Base64 strings in JSON (not file paths) |
|
||||
| MSP logo location | `BrandingSettings.MspLogo` → `branding.json` via `BrandingRepository` |
|
||||
| Client logo location | `TenantProfile.ClientLogo` (per-tenant, in profile JSON) |
|
||||
| Logo model | `LogoData { string Base64, string MimeType }` — shared by both MSP and client logos |
|
||||
| SVG support | Rejected (XSS risk) — PNG/JPG only |
|
||||
| Export service signature change | Optional `ReportBranding? branding = null` parameter on existing `BuildHtml` methods |
|
||||
| No new interfaces | No `IHtmlExportService<T>` — keep concrete classes with optional branding param |
|
||||
| Report header layout | `display: flex; gap: 16px` — MSP logo left, client logo right |
|
||||
| Logo HTML format | `<img src="data:{MimeType};base64,{Base64}">` inline data-URI |
|
||||
| No new NuGet packages | All capabilities provided by existing stack |
|
||||
|
||||
## Phase Goal
|
||||
|
||||
All five HTML reports display MSP and client logos in a consistent header, and administrators can manage logos from Settings and the profile dialog without touching the View layer.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. Running any of the five HTML exports (Permissions, Storage, Search, Duplicates, User Access) produces an HTML file whose header contains the MSP logo `<img>` tag when an MSP logo is configured
|
||||
2. When a client logo is configured for the active tenant, the same HTML export header contains both the MSP logo and the client logo side by side
|
||||
3. When no logo is configured, the HTML export header contains no broken image placeholder and the report renders identically to the pre-branding output
|
||||
4. SettingsViewModel exposes browse/clear commands for MSP logo; ProfileManagementViewModel exposes browse/clear commands for client logo — both commands are exercisable without opening any View
|
||||
5. Auto-pulling the client logo from the tenant's Entra branding API stores the logo in the tenant profile and falls back silently when no Entra branding is configured
|
||||
|
||||
## Depends On
|
||||
|
||||
Phase 10 (completed) — provides `LogoData`, `BrandingSettings`, `BrandingRepository`, `IBrandingService`, `TenantProfile.ClientLogo`
|
||||
|
||||
## Requirements Mapped
|
||||
|
||||
- **BRAND-05**: Logos appear in HTML report headers
|
||||
- **BRAND-04**: Auto-pull client logo from Entra branding API
|
||||
|
||||
## Code Context
|
||||
|
||||
### Phase 10 Infrastructure (already built)
|
||||
|
||||
| Asset | Path | Role |
|
||||
|---|---|---|
|
||||
| LogoData record | `Core/Models/LogoData.cs` | `{ string Base64, string MimeType }` |
|
||||
| BrandingSettings model | `Core/Models/BrandingSettings.cs` | `{ LogoData? MspLogo }` |
|
||||
| TenantProfile model | `Core/Models/TenantProfile.cs` | `{ LogoData? ClientLogo }` (per-tenant) |
|
||||
| IBrandingService | `Services/IBrandingService.cs` | `ImportLogoAsync`, `SaveMspLogoAsync`, `ClearMspLogoAsync`, `GetMspLogoAsync` |
|
||||
| BrandingService | `Services/BrandingService.cs` | Validates PNG/JPG via magic bytes, auto-compresses >512KB |
|
||||
| BrandingRepository | `Infrastructure/Persistence/BrandingRepository.cs` | JSON persistence with SemaphoreSlim + atomic write |
|
||||
|
||||
### HTML Export Services (5 targets for branding injection)
|
||||
|
||||
| Service | Path | `BuildHtml` Signature | Header Location |
|
||||
|---|---|---|---|
|
||||
| HtmlExportService | `Services/Export/HtmlExportService.cs` | `BuildHtml(IReadOnlyList<PermissionEntry>)` | `<h1>SharePoint Permissions Report</h1>` at line 76 |
|
||||
| HtmlExportService (simplified) | Same file | `BuildHtml(IReadOnlyList<SimplifiedPermissionEntry>)` (2nd overload) | Similar pattern |
|
||||
| SearchHtmlExportService | `Services/Export/SearchHtmlExportService.cs` | `BuildHtml(IReadOnlyList<SearchResult>)` | `<h1>File Search Results</h1>` at line 46 |
|
||||
| StorageHtmlExportService | `Services/Export/StorageHtmlExportService.cs` | `BuildHtml(IReadOnlyList<StorageNode>)` | `<h1>SharePoint Storage Metrics</h1>` at line 51 |
|
||||
| DuplicatesHtmlExportService | `Services/Export/DuplicatesHtmlExportService.cs` | `BuildHtml(IReadOnlyList<DuplicateGroup>)` | `<h1>Duplicate Detection Report</h1>` at line 55 |
|
||||
| UserAccessHtmlExportService | `Services/Export/UserAccessHtmlExportService.cs` | `BuildHtml(IReadOnlyList<UserAccessEntry>)` | `<h1>User Access Audit Report</h1>` at line 91 |
|
||||
|
||||
### WriteAsync Signatures (7 overloads across 5 services)
|
||||
|
||||
```csharp
|
||||
// HtmlExportService.cs
|
||||
WriteAsync(IReadOnlyList<PermissionEntry>, string filePath, CancellationToken)
|
||||
WriteAsync(IReadOnlyList<SimplifiedPermissionEntry>, string filePath, CancellationToken)
|
||||
|
||||
// SearchHtmlExportService.cs
|
||||
WriteAsync(IReadOnlyList<SearchResult>, string filePath, CancellationToken)
|
||||
|
||||
// StorageHtmlExportService.cs
|
||||
WriteAsync(IReadOnlyList<StorageNode>, string filePath, CancellationToken)
|
||||
WriteAsync(IReadOnlyList<StorageNode>, IReadOnlyList<FileTypeMetric>, string filePath, CancellationToken)
|
||||
|
||||
// DuplicatesHtmlExportService.cs
|
||||
WriteAsync(IReadOnlyList<DuplicateGroup>, string filePath, CancellationToken)
|
||||
|
||||
// UserAccessHtmlExportService.cs
|
||||
WriteAsync(IReadOnlyList<UserAccessEntry>, string filePath, CancellationToken)
|
||||
```
|
||||
|
||||
### ViewModels That Trigger Exports (5 targets)
|
||||
|
||||
| ViewModel | Path | Export Call Pattern |
|
||||
|---|---|---|
|
||||
| PermissionsViewModel | `ViewModels/Tabs/PermissionsViewModel.cs` | `_htmlExportService.WriteAsync(Results/SimplifiedResults, ...)` |
|
||||
| SearchViewModel | `ViewModels/Tabs/SearchViewModel.cs` | `_htmlExportService.WriteAsync(Results, ...)` |
|
||||
| StorageViewModel | `ViewModels/Tabs/StorageViewModel.cs` | `_htmlExportService.WriteAsync(Results, FileTypeMetrics, ...)` |
|
||||
| DuplicatesViewModel | `ViewModels/Tabs/DuplicatesViewModel.cs` | `_htmlExportService.WriteAsync(_lastGroups, ...)` |
|
||||
| UserAccessAuditViewModel | `ViewModels/Tabs/UserAccessAuditViewModel.cs` | `_htmlExportService.WriteAsync(Results, ...)` |
|
||||
|
||||
### Logo Management ViewModels (2 targets)
|
||||
|
||||
| ViewModel | Path | Current State |
|
||||
|---|---|---|
|
||||
| SettingsViewModel | `ViewModels/Tabs/SettingsViewModel.cs` | Has language + data folder; needs MSP logo browse/clear commands |
|
||||
| ProfileManagementViewModel | `ViewModels/ProfileManagementViewModel.cs` | Has CRUD profiles; needs client logo browse/clear/auto-pull commands |
|
||||
|
||||
### DI Registration
|
||||
|
||||
`App.xaml.cs` — All export services registered as `Transient`, branding services registered as `Singleton`.
|
||||
|
||||
### HTML Generation Pattern
|
||||
|
||||
All 5 HTML exporters use StringBuilder with inline HTML/CSS/JS. No template files. Each builds a self-contained single-file report. The branding header must be injected between `<body>` and the existing `<h1>` tag in each exporter.
|
||||
|
||||
## Deferred Ideas (out of scope for Phase 11)
|
||||
|
||||
- Logo preview in Settings UI (Phase 12)
|
||||
- Live thumbnail preview after import (Phase 12)
|
||||
- "Pull from Entra" button in profile dialog UI (Phase 12)
|
||||
- User directory browse mode (Phase 13-14)
|
||||
99
.planning/phases/11-html-export-branding/11-VALIDATION.md
Normal file
99
.planning/phases/11-html-export-branding/11-VALIDATION.md
Normal file
@@ -0,0 +1,99 @@
|
||||
---
|
||||
phase: 11
|
||||
slug: html-export-branding
|
||||
status: draft
|
||||
nyquist_compliant: true
|
||||
wave_0_complete: false
|
||||
created: 2026-04-08
|
||||
---
|
||||
|
||||
# Phase 11 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | xUnit 2.9.3 + Moq 4.20.72 |
|
||||
| **Config file** | `SharepointToolbox.Tests/SharepointToolbox.Tests.csproj` |
|
||||
| **Quick run command** | `dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~Export" --no-build -q` |
|
||||
| **Full suite command** | `dotnet test SharepointToolbox.Tests --no-build` |
|
||||
| **Estimated runtime** | ~15 seconds |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** `dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~Export" --no-build -q`
|
||||
- **After every plan wave:** `dotnet test SharepointToolbox.Tests --no-build`
|
||||
- **Before `/gsd:verify-work`:** Full suite must be green
|
||||
- **Max feedback latency:** 15 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
|
||||
| 11-01-01 | 01 | 1 | BRAND-05 | unit | `dotnet test --filter "FullyQualifiedName~BrandingHtmlHelper" --no-build` | No (W0) | pending |
|
||||
| 11-02-01 | 02 | 2 | BRAND-05 | unit | `dotnet test --filter "FullyQualifiedName~Export" --no-build -q` | Yes (extend) | pending |
|
||||
| 11-02-02 | 02 | 2 | BRAND-05 | unit | same as above | Yes (extend) | pending |
|
||||
| 11-03-01 | 03 | 3 | BRAND-05 | integration | `dotnet build --no-restore -warnaserror && dotnet test --no-build -q` | Yes (compile check) | pending |
|
||||
| 11-04-01 | 04 | 1 | BRAND-04 | unit | `dotnet test --filter "FullyQualifiedName~ProfileService" --no-build` | Yes (extend) | pending |
|
||||
| 11-04-02 | 04 | 1 | BRAND-04 | unit | `dotnet test --filter "FullyQualifiedName~SettingsViewModel or FullyQualifiedName~ProfileManagement" --no-build` | No (W0) | pending |
|
||||
|
||||
*Status: pending / green / red / flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `SharepointToolbox.Tests/Services/Export/BrandingHtmlHelperTests.cs` — covers BRAND-05a/b/c (both logos, single logo, no logos)
|
||||
- [ ] `SharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs` — covers MSP logo browse/clear commands
|
||||
- [ ] `SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs` — covers client logo + auto-pull (BRAND-04)
|
||||
- [ ] Extend existing `HtmlExportServiceTests.cs` — covers BRAND-05d/e (branding present/absent)
|
||||
- [ ] Extend existing `SearchExportServiceTests.cs`, `StorageHtmlExportServiceTests.cs`, `DuplicatesHtmlExportServiceTests.cs`, `UserAccessHtmlExportServiceTests.cs` — covers BRAND-05f
|
||||
|
||||
*Existing infrastructure covers test framework setup.*
|
||||
|
||||
---
|
||||
|
||||
## Requirements -> Test Map
|
||||
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| BRAND-05a | BrandingHtmlHelper produces correct HTML for both logos | unit | `dotnet test --filter "FullyQualifiedName~BrandingHtmlHelper" --no-build -q` | No - Wave 0 |
|
||||
| BRAND-05b | BrandingHtmlHelper produces empty string for no logos | unit | same as above | No - Wave 0 |
|
||||
| BRAND-05c | BrandingHtmlHelper handles single logo (MSP only / client only) | unit | same as above | No - Wave 0 |
|
||||
| BRAND-05d | HtmlExportService.BuildHtml with branding includes header | unit | `dotnet test --filter "FullyQualifiedName~HtmlExportServiceTests" --no-build -q` | Yes (extend) |
|
||||
| BRAND-05e | HtmlExportService.BuildHtml without branding unchanged | unit | same as above | Yes (extend) |
|
||||
| BRAND-05f | Each of 5 exporters injects branding header between body and h1 | unit | `dotnet test --filter "FullyQualifiedName~Export" --no-build -q` | Partially (extend existing) |
|
||||
| BRAND-04a | Auto-pull handles 404 (no branding) gracefully | unit | `dotnet test --filter "FullyQualifiedName~AutoPull" --no-build -q` | No - Wave 0 |
|
||||
| BRAND-04b | Auto-pull handles empty stream gracefully | unit | same as above | No - Wave 0 |
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| MSP logo appears in exported HTML report | BRAND-05 | Requires visual inspection of rendered HTML | 1. Import MSP logo 2. Run permissions export 3. Open HTML in browser 4. Verify logo in header |
|
||||
| Both logos side by side in report header | BRAND-05 | Requires visual layout check | 1. Import MSP and client logo 2. Run any export 3. Verify both logos rendered side by side |
|
||||
| No broken images when no logo configured | BRAND-05 | Requires visual regression check | 1. Clear all logos 2. Run export 3. Compare output to pre-branding export |
|
||||
| Auto-pull from tenant without Entra branding | BRAND-04 | Requires live tenant without branding | 1. Select tenant without Entra branding 2. Click auto-pull 3. Verify silent fallback (no crash, no broken state) |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [x] Wave 0 covers all MISSING references
|
||||
- [x] No watch-mode flags
|
||||
- [x] Feedback latency < 15s
|
||||
- [x] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** approved
|
||||
351
.planning/phases/12-branding-ui-views/12-01-PLAN.md
Normal file
351
.planning/phases/12-branding-ui-views/12-01-PLAN.md
Normal file
@@ -0,0 +1,351 @@
|
||||
---
|
||||
phase: 12-branding-ui-views
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- SharepointToolbox/Views/Converters/Base64ToImageSourceConverter.cs
|
||||
- SharepointToolbox/App.xaml
|
||||
- SharepointToolbox/Localization/Strings.resx
|
||||
- SharepointToolbox/Localization/Strings.fr.resx
|
||||
- SharepointToolbox/ViewModels/ProfileManagementViewModel.cs
|
||||
- SharepointToolbox.Tests/Converters/Base64ToImageSourceConverterTests.cs
|
||||
- SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs
|
||||
autonomous: true
|
||||
requirements:
|
||||
- BRAND-02
|
||||
- BRAND-04
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Base64ToImageSourceConverter converts a data URI string to a non-null BitmapImage"
|
||||
- "Base64ToImageSourceConverter returns null for null, empty, or malformed input"
|
||||
- "Converter is registered in App.xaml as a global resource with key Base64ToImageConverter"
|
||||
- "ProfileManagementViewModel exposes ClientLogoPreview (string?) that updates when SelectedProfile changes, and after Browse/Clear/AutoPull commands"
|
||||
- "Localization keys for logo UI exist in both EN and FR resource files"
|
||||
artifacts:
|
||||
- path: "SharepointToolbox/Views/Converters/Base64ToImageSourceConverter.cs"
|
||||
provides: "IValueConverter converting data URI strings to BitmapImage for WPF Image binding"
|
||||
contains: "class Base64ToImageSourceConverter"
|
||||
- path: "SharepointToolbox/App.xaml"
|
||||
provides: "Global converter registration"
|
||||
contains: "Base64ToImageConverter"
|
||||
- path: "SharepointToolbox/ViewModels/ProfileManagementViewModel.cs"
|
||||
provides: "ClientLogoPreview observable property for client logo display"
|
||||
contains: "ClientLogoPreview"
|
||||
- path: "SharepointToolbox.Tests/Converters/Base64ToImageSourceConverterTests.cs"
|
||||
provides: "Unit tests for converter behavior"
|
||||
min_lines: 30
|
||||
key_links:
|
||||
- from: "SharepointToolbox/Views/Converters/Base64ToImageSourceConverter.cs"
|
||||
to: "SharepointToolbox/App.xaml"
|
||||
via: "resource registration"
|
||||
pattern: "Base64ToImageConverter"
|
||||
- from: "SharepointToolbox/ViewModels/ProfileManagementViewModel.cs"
|
||||
to: "SharepointToolbox/Core/Models/LogoData.cs"
|
||||
via: "data URI formatting"
|
||||
pattern: "ClientLogoPreview"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create the Base64ToImageSourceConverter, add localization keys for logo UI, register the converter globally, and add the ClientLogoPreview property to ProfileManagementViewModel.
|
||||
|
||||
Purpose: Provides the infrastructure (converter, localization, ViewModel property) that Plans 02 and 03 need to build the XAML views.
|
||||
|
||||
Output: Converter with tests, localization keys (EN+FR), App.xaml registration, ClientLogoPreview property with 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/STATE.md
|
||||
@.planning/phases/12-branding-ui-views/12-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- SettingsViewModel pattern for logo preview (reference for ProfileManagementViewModel) -->
|
||||
|
||||
From SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs:
|
||||
```csharp
|
||||
private string? _mspLogoPreview;
|
||||
public string? MspLogoPreview
|
||||
{
|
||||
get => _mspLogoPreview;
|
||||
private set { _mspLogoPreview = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
// Set in LoadAsync:
|
||||
var mspLogo = await _brandingService.GetMspLogoAsync();
|
||||
MspLogoPreview = mspLogo is not null ? $"data:{mspLogo.MimeType};base64,{mspLogo.Base64}" : null;
|
||||
|
||||
// Set in BrowseMspLogoAsync:
|
||||
MspLogoPreview = $"data:{logo.MimeType};base64,{logo.Base64}";
|
||||
|
||||
// Set in ClearMspLogoAsync:
|
||||
MspLogoPreview = null;
|
||||
```
|
||||
|
||||
From SharepointToolbox/ViewModels/ProfileManagementViewModel.cs (current state):
|
||||
```csharp
|
||||
// BrowseClientLogoAsync sets SelectedProfile.ClientLogo = logo (LogoData)
|
||||
// ClearClientLogoAsync sets SelectedProfile.ClientLogo = null
|
||||
// AutoPullClientLogoAsync sets SelectedProfile.ClientLogo = logo
|
||||
// NO ClientLogoPreview string property exists — this plan adds it
|
||||
```
|
||||
|
||||
From SharepointToolbox/Core/Models/LogoData.cs:
|
||||
```csharp
|
||||
public record LogoData
|
||||
{
|
||||
public string Base64 { get; init; } = string.Empty;
|
||||
public string MimeType { get; init; } = string.Empty;
|
||||
}
|
||||
```
|
||||
|
||||
From SharepointToolbox/Views/Converters/IndentConverter.cs (converter pattern):
|
||||
```csharp
|
||||
public class StringToVisibilityConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> value is string s && !string.IsNullOrEmpty(s) ? Visibility.Visible : Visibility.Collapsed;
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
```
|
||||
|
||||
From SharepointToolbox/App.xaml (converter registration pattern):
|
||||
```xml
|
||||
<conv:StringToVisibilityConverter x:Key="StringToVisibilityConverter" />
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Create Base64ToImageSourceConverter with tests</name>
|
||||
<files>
|
||||
SharepointToolbox/Views/Converters/Base64ToImageSourceConverter.cs,
|
||||
SharepointToolbox.Tests/Converters/Base64ToImageSourceConverterTests.cs
|
||||
</files>
|
||||
<behavior>
|
||||
- Test 1: Convert with null value returns null
|
||||
- Test 2: Convert with empty string returns null
|
||||
- Test 3: Convert with non-string value returns null
|
||||
- Test 4: Convert with valid data URI "data:image/png;base64,{validBase64}" returns a non-null BitmapImage
|
||||
- Test 5: Convert with malformed string (no "base64," prefix) returns null (does not throw)
|
||||
- Test 6: ConvertBack throws NotImplementedException
|
||||
</behavior>
|
||||
<action>
|
||||
1. Create `SharepointToolbox/Views/Converters/Base64ToImageSourceConverter.cs`:
|
||||
```csharp
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Media.Imaging;
|
||||
|
||||
namespace SharepointToolbox.Views.Converters;
|
||||
|
||||
public class Base64ToImageSourceConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not string dataUri || string.IsNullOrEmpty(dataUri))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var marker = "base64,";
|
||||
var idx = dataUri.IndexOf(marker, StringComparison.Ordinal);
|
||||
if (idx < 0) return null;
|
||||
|
||||
var base64 = dataUri[(idx + marker.Length)..];
|
||||
var bytes = System.Convert.FromBase64String(base64);
|
||||
|
||||
var image = new BitmapImage();
|
||||
using var ms = new MemoryStream(bytes);
|
||||
image.BeginInit();
|
||||
image.CacheOption = BitmapCacheOption.OnLoad;
|
||||
image.StreamSource = ms;
|
||||
image.EndInit();
|
||||
image.Freeze();
|
||||
return image;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
```
|
||||
Key decisions:
|
||||
- Parses data URI by finding "base64," marker — works with any mime type
|
||||
- `BitmapCacheOption.OnLoad` ensures the stream can be disposed immediately
|
||||
- `Freeze()` makes the image cross-thread safe (required for WPF binding)
|
||||
- Catches all exceptions to avoid binding errors — returns null on failure
|
||||
|
||||
2. Create `SharepointToolbox.Tests/Converters/Base64ToImageSourceConverterTests.cs`:
|
||||
Write tests FIRST (RED), then verify GREEN.
|
||||
Use `[Trait("Category", "Unit")]` per project convention.
|
||||
Note: Tests that create BitmapImage need `[STAThread]` or run on STA thread. Use xUnit's `[WpfFact]` from `Xunit.StaFact` if available, or mark tests with `[Fact]` and handle STA requirement.
|
||||
For the valid data URI test, use a minimal valid 1x1 PNG base64: `iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==`
|
||||
|
||||
IMPORTANT: Check if `Xunit.StaFact` NuGet package is referenced in the test project. If not, the BitmapImage tests may need to be skipped or use a workaround (run converter logic that doesn't need STA for null/empty cases, skip the BitmapImage creation test if STA not available).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>dotnet build --no-restore -warnaserror && dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~Base64ToImageSourceConverter" --no-build -q</automated>
|
||||
</verify>
|
||||
<done>Converter class exists, handles all edge cases without throwing, tests pass.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Register converter in App.xaml</name>
|
||||
<files>
|
||||
SharepointToolbox/App.xaml
|
||||
</files>
|
||||
<behavior>
|
||||
- App.xaml contains a Base64ToImageSourceConverter resource with key "Base64ToImageConverter"
|
||||
</behavior>
|
||||
<action>
|
||||
1. In `SharepointToolbox/App.xaml`, add inside `<Application.Resources>`:
|
||||
```xml
|
||||
<conv:Base64ToImageSourceConverter x:Key="Base64ToImageConverter" />
|
||||
```
|
||||
Place it after the existing converter registrations.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>dotnet build --no-restore -warnaserror</automated>
|
||||
</verify>
|
||||
<done>Converter is globally available via StaticResource Base64ToImageConverter.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Add localization keys for logo UI (EN + FR)</name>
|
||||
<files>
|
||||
SharepointToolbox/Localization/Strings.resx,
|
||||
SharepointToolbox/Localization/Strings.fr.resx
|
||||
</files>
|
||||
<behavior>
|
||||
- Both resx files contain matching keys for logo UI labels
|
||||
</behavior>
|
||||
<action>
|
||||
1. Add to `Strings.resx` (EN):
|
||||
- `settings.logo.title` = "MSP Logo"
|
||||
- `settings.logo.browse` = "Import"
|
||||
- `settings.logo.clear` = "Clear"
|
||||
- `settings.logo.nopreview` = "No logo configured"
|
||||
- `profile.logo.title` = "Client Logo"
|
||||
- `profile.logo.browse` = "Import"
|
||||
- `profile.logo.clear` = "Clear"
|
||||
- `profile.logo.autopull` = "Pull from Entra"
|
||||
- `profile.logo.nopreview` = "No logo configured"
|
||||
|
||||
2. Add to `Strings.fr.resx` (FR):
|
||||
- `settings.logo.title` = "Logo MSP"
|
||||
- `settings.logo.browse` = "Importer"
|
||||
- `settings.logo.clear` = "Effacer"
|
||||
- `settings.logo.nopreview` = "Aucun logo configuré"
|
||||
- `profile.logo.title` = "Logo client"
|
||||
- `profile.logo.browse` = "Importer"
|
||||
- `profile.logo.clear` = "Effacer"
|
||||
- `profile.logo.autopull` = "Importer depuis Entra"
|
||||
- `profile.logo.nopreview` = "Aucun logo configuré"
|
||||
</action>
|
||||
<verify>
|
||||
<automated>dotnet build --no-restore -warnaserror</automated>
|
||||
</verify>
|
||||
<done>All 9 localization keys exist in both EN and FR resource files.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Add ClientLogoPreview property to ProfileManagementViewModel</name>
|
||||
<files>
|
||||
SharepointToolbox/ViewModels/ProfileManagementViewModel.cs,
|
||||
SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs
|
||||
</files>
|
||||
<behavior>
|
||||
- ProfileManagementViewModel exposes ClientLogoPreview (string?) property
|
||||
- ClientLogoPreview updates to data URI when SelectedProfile changes and has a ClientLogo
|
||||
- ClientLogoPreview updates to null when SelectedProfile is null or has no ClientLogo
|
||||
- BrowseClientLogoAsync updates ClientLogoPreview after successful import
|
||||
- ClearClientLogoAsync sets ClientLogoPreview to null
|
||||
- AutoPullClientLogoAsync updates ClientLogoPreview after successful pull
|
||||
</behavior>
|
||||
<action>
|
||||
1. Add to `ProfileManagementViewModel.cs`:
|
||||
```csharp
|
||||
private string? _clientLogoPreview;
|
||||
public string? ClientLogoPreview
|
||||
{
|
||||
get => _clientLogoPreview;
|
||||
private set { _clientLogoPreview = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
private static string? FormatLogoPreview(LogoData? logo)
|
||||
=> logo is not null ? $"data:{logo.MimeType};base64,{logo.Base64}" : null;
|
||||
```
|
||||
|
||||
2. Update `OnSelectedProfileChanged` to refresh preview:
|
||||
```csharp
|
||||
partial void OnSelectedProfileChanged(TenantProfile? value)
|
||||
{
|
||||
ClientLogoPreview = FormatLogoPreview(value?.ClientLogo);
|
||||
// ... existing NotifyCanExecuteChanged calls ...
|
||||
}
|
||||
```
|
||||
|
||||
3. Update `BrowseClientLogoAsync` — after `SelectedProfile.ClientLogo = logo;` add:
|
||||
```csharp
|
||||
ClientLogoPreview = FormatLogoPreview(logo);
|
||||
```
|
||||
|
||||
4. Update `ClearClientLogoAsync` — after `SelectedProfile.ClientLogo = null;` add:
|
||||
```csharp
|
||||
ClientLogoPreview = null;
|
||||
```
|
||||
|
||||
5. Update `AutoPullClientLogoAsync` — after `SelectedProfile.ClientLogo = logo;` add:
|
||||
```csharp
|
||||
ClientLogoPreview = FormatLogoPreview(logo);
|
||||
```
|
||||
|
||||
6. Update existing tests in `ProfileManagementViewModelLogoTests.cs`:
|
||||
- Add test: ClientLogoPreview is null when no profile selected
|
||||
- Add test: ClientLogoPreview updates when SelectedProfile with logo is selected
|
||||
- Add test: ClearClientLogoAsync sets ClientLogoPreview to null
|
||||
</action>
|
||||
<verify>
|
||||
<automated>dotnet build --no-restore -warnaserror && dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~ProfileManagementViewModel" --no-build -q</automated>
|
||||
</verify>
|
||||
<done>ClientLogoPreview property exists and stays in sync with SelectedProfile.ClientLogo across all mutations. Tests pass.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
dotnet build --no-restore -warnaserror
|
||||
dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~Base64ToImageSourceConverter|FullyQualifiedName~ProfileManagementViewModel" --no-build -q
|
||||
```
|
||||
Both commands must pass with zero failures.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Base64ToImageSourceConverter converts data URI strings to BitmapImage, returns null on bad input
|
||||
- Converter registered in App.xaml as "Base64ToImageConverter"
|
||||
- 9 localization keys present in both Strings.resx and Strings.fr.resx
|
||||
- ProfileManagementViewModel.ClientLogoPreview stays in sync with SelectedProfile.ClientLogo
|
||||
- All tests pass, build succeeds with zero warnings
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/12-branding-ui-views/12-01-SUMMARY.md`
|
||||
</output>
|
||||
182
.planning/phases/12-branding-ui-views/12-02-PLAN.md
Normal file
182
.planning/phases/12-branding-ui-views/12-02-PLAN.md
Normal file
@@ -0,0 +1,182 @@
|
||||
---
|
||||
phase: 12-branding-ui-views
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [12-01]
|
||||
files_modified:
|
||||
- SharepointToolbox/Views/Tabs/SettingsView.xaml
|
||||
autonomous: true
|
||||
requirements:
|
||||
- BRAND-02
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "SettingsView displays an MSP Logo section with a labeled GroupBox below the data folder section"
|
||||
- "The logo section shows a live thumbnail preview bound to MspLogoPreview via Base64ToImageConverter"
|
||||
- "When MspLogoPreview is null, the preview area shows a 'No logo configured' placeholder text"
|
||||
- "Import and Clear buttons are bound to BrowseMspLogoCommand and ClearMspLogoCommand respectively"
|
||||
- "StatusMessage displays below the logo section when set"
|
||||
artifacts:
|
||||
- path: "SharepointToolbox/Views/Tabs/SettingsView.xaml"
|
||||
provides: "MSP logo section with live preview, import, and clear controls"
|
||||
contains: "MspLogoPreview"
|
||||
key_links:
|
||||
- from: "SharepointToolbox/Views/Tabs/SettingsView.xaml"
|
||||
to: "SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs"
|
||||
via: "data binding"
|
||||
pattern: "BrowseMspLogoCommand|ClearMspLogoCommand|MspLogoPreview"
|
||||
- from: "SharepointToolbox/Views/Tabs/SettingsView.xaml"
|
||||
to: "SharepointToolbox/Views/Converters/Base64ToImageSourceConverter.cs"
|
||||
via: "StaticResource"
|
||||
pattern: "Base64ToImageConverter"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add the MSP logo section to SettingsView.xaml with live thumbnail preview, Import and Clear buttons.
|
||||
|
||||
Purpose: Allows administrators to see the current MSP logo and manage it directly from the Settings tab.
|
||||
|
||||
Output: Updated SettingsView.xaml with a logo section that binds to existing ViewModel commands and properties.
|
||||
</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/STATE.md
|
||||
@.planning/phases/12-branding-ui-views/12-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- SettingsViewModel properties and commands (already exist from Phase 11) -->
|
||||
|
||||
From SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs:
|
||||
```csharp
|
||||
public string? MspLogoPreview { get; } // data URI string or null
|
||||
public IAsyncRelayCommand BrowseMspLogoCommand { get; }
|
||||
public IAsyncRelayCommand ClearMspLogoCommand { get; }
|
||||
public string StatusMessage { get; set; } // inherited from FeatureViewModelBase
|
||||
```
|
||||
|
||||
<!-- Current SettingsView.xaml structure -->
|
||||
From SharepointToolbox/Views/Tabs/SettingsView.xaml:
|
||||
```xml
|
||||
<UserControl ...
|
||||
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
|
||||
<StackPanel Margin="16">
|
||||
<!-- Language section -->
|
||||
<Label Content="{Binding Source=..., Path=[settings.language]}" />
|
||||
<ComboBox ... />
|
||||
<Separator Margin="0,12" />
|
||||
<!-- Data folder section -->
|
||||
<Label Content="{Binding Source=..., Path=[settings.folder]}" />
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" ... Command="{Binding BrowseFolderCommand}" />
|
||||
<TextBox Text="{Binding DataFolder, ...}" />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
```
|
||||
|
||||
<!-- Available converters from App.xaml -->
|
||||
- `{StaticResource Base64ToImageConverter}` — converts data URI string to BitmapImage (added in 12-01)
|
||||
- `{StaticResource StringToVisibilityConverter}` — returns Visible if string non-empty, else Collapsed
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add MSP logo section to SettingsView.xaml</name>
|
||||
<files>
|
||||
SharepointToolbox/Views/Tabs/SettingsView.xaml
|
||||
</files>
|
||||
<behavior>
|
||||
- Below the data folder DockPanel, a Separator and a new MSP Logo section appears
|
||||
- The section has a Label with localized "MSP Logo" text
|
||||
- A Border contains either an Image (when logo exists) or a TextBlock placeholder (when no logo)
|
||||
- Image is bound to MspLogoPreview via Base64ToImageConverter, max 80px height, max 240px width
|
||||
- Placeholder TextBlock shows localized "No logo configured" text, visible only when MspLogoPreview is null/empty
|
||||
- Two buttons (Import, Clear) are horizontally aligned below the preview
|
||||
- A TextBlock shows StatusMessage when set (for error feedback)
|
||||
</behavior>
|
||||
<action>
|
||||
1. Edit `SharepointToolbox/Views/Tabs/SettingsView.xaml`:
|
||||
After the Data folder `</DockPanel>`, before `</StackPanel>`, add:
|
||||
|
||||
```xml
|
||||
<Separator Margin="0,12" />
|
||||
|
||||
<!-- MSP Logo -->
|
||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.logo.title]}" />
|
||||
<Border BorderBrush="#DDDDDD" BorderThickness="1" Padding="8" CornerRadius="4"
|
||||
HorizontalAlignment="Left" MinWidth="200" MinHeight="60" Margin="0,4,0,0">
|
||||
<Grid>
|
||||
<Image Source="{Binding MspLogoPreview, Converter={StaticResource Base64ToImageConverter}}"
|
||||
MaxHeight="80" MaxWidth="240" Stretch="Uniform" HorizontalAlignment="Left"
|
||||
Visibility="{Binding MspLogoPreview, Converter={StaticResource StringToVisibilityConverter}}" />
|
||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.logo.nopreview]}"
|
||||
VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||
Foreground="#999999" FontStyle="Italic">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Visibility" Value="Visible" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding MspLogoPreview, Converter={StaticResource StringToVisibilityConverter}}" Value="Visible">
|
||||
<Setter Property="Visibility" Value="Collapsed" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
</Border>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.logo.browse]}"
|
||||
Command="{Binding BrowseMspLogoCommand}" Width="80" Margin="0,0,8,0" />
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.logo.clear]}"
|
||||
Command="{Binding ClearMspLogoCommand}" Width="80" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding StatusMessage}" Foreground="#CC0000" FontSize="11" Margin="0,4,0,0"
|
||||
Visibility="{Binding StatusMessage, Converter={StaticResource StringToVisibilityConverter}}" />
|
||||
```
|
||||
|
||||
Key decisions:
|
||||
- Border with light gray outline creates a visual container for the logo preview
|
||||
- Grid overlays Image and placeholder TextBlock — only one visible at a time
|
||||
- DataTrigger hides placeholder when StringToVisibilityConverter returns Visible
|
||||
- MaxHeight="80" and MaxWidth="240" keep the preview small but readable
|
||||
- Stretch="Uniform" preserves aspect ratio
|
||||
- StatusMessage in red only shows when non-empty (error feedback from import failures)
|
||||
</action>
|
||||
<verify>
|
||||
<automated>dotnet build --no-restore -warnaserror</automated>
|
||||
</verify>
|
||||
<done>SettingsView shows MSP logo section with live preview, Import/Clear buttons, and error message area. Build passes.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
dotnet build --no-restore -warnaserror
|
||||
```
|
||||
Build must pass with zero failures. Visual verification requires manual testing.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SettingsView.xaml has a visible MSP Logo section below the data folder
|
||||
- Image binds to MspLogoPreview via Base64ToImageConverter
|
||||
- Placeholder text shows when no logo is configured
|
||||
- Import and Clear buttons bind to existing ViewModel commands
|
||||
- StatusMessage displays in red when set
|
||||
- Build passes with zero warnings
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/12-branding-ui-views/12-02-SUMMARY.md`
|
||||
</output>
|
||||
203
.planning/phases/12-branding-ui-views/12-03-PLAN.md
Normal file
203
.planning/phases/12-branding-ui-views/12-03-PLAN.md
Normal file
@@ -0,0 +1,203 @@
|
||||
---
|
||||
phase: 12-branding-ui-views
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [12-01]
|
||||
files_modified:
|
||||
- SharepointToolbox/Views/Dialogs/ProfileManagementDialog.xaml
|
||||
autonomous: true
|
||||
requirements:
|
||||
- BRAND-04
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "ProfileManagementDialog shows a Client Logo section between the input fields and the action buttons"
|
||||
- "The logo section shows a live thumbnail preview bound to ClientLogoPreview via Base64ToImageConverter"
|
||||
- "When ClientLogoPreview is null, the preview area shows a 'No logo configured' placeholder text"
|
||||
- "Import, Clear, and Pull from Entra buttons are bound to BrowseClientLogoCommand, ClearClientLogoCommand, and AutoPullClientLogoCommand respectively"
|
||||
- "All three logo buttons are disabled when no profile is selected"
|
||||
- "ValidationMessage displays below the logo buttons when set"
|
||||
- "Dialog height is increased to accommodate the new section"
|
||||
artifacts:
|
||||
- path: "SharepointToolbox/Views/Dialogs/ProfileManagementDialog.xaml"
|
||||
provides: "Client logo section with live preview, import, clear, and auto-pull controls"
|
||||
contains: "ClientLogoPreview"
|
||||
key_links:
|
||||
- from: "SharepointToolbox/Views/Dialogs/ProfileManagementDialog.xaml"
|
||||
to: "SharepointToolbox/ViewModels/ProfileManagementViewModel.cs"
|
||||
via: "data binding"
|
||||
pattern: "BrowseClientLogoCommand|ClearClientLogoCommand|AutoPullClientLogoCommand|ClientLogoPreview"
|
||||
- from: "SharepointToolbox/Views/Dialogs/ProfileManagementDialog.xaml"
|
||||
to: "SharepointToolbox/Views/Converters/Base64ToImageSourceConverter.cs"
|
||||
via: "StaticResource"
|
||||
pattern: "Base64ToImageConverter"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add the client logo section to ProfileManagementDialog.xaml with live thumbnail preview, Import, Clear, and Pull from Entra buttons.
|
||||
|
||||
Purpose: Allows administrators to see, import, clear, and auto-pull client logos per tenant directly from the profile management dialog.
|
||||
|
||||
Output: Updated ProfileManagementDialog.xaml with a client logo section and increased dialog height.
|
||||
</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/STATE.md
|
||||
@.planning/phases/12-branding-ui-views/12-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- ProfileManagementViewModel properties and commands (Phase 11 + 12-01) -->
|
||||
|
||||
From SharepointToolbox/ViewModels/ProfileManagementViewModel.cs:
|
||||
```csharp
|
||||
public string? ClientLogoPreview { get; } // data URI string or null (added in 12-01)
|
||||
public IAsyncRelayCommand BrowseClientLogoCommand { get; } // gated on SelectedProfile != null
|
||||
public IAsyncRelayCommand ClearClientLogoCommand { get; } // gated on SelectedProfile != null
|
||||
public IAsyncRelayCommand AutoPullClientLogoCommand { get; } // gated on SelectedProfile != null
|
||||
public string ValidationMessage { get; set; } // set on error or success feedback
|
||||
public TenantProfile? SelectedProfile { get; set; }
|
||||
```
|
||||
|
||||
<!-- Current ProfileManagementDialog.xaml structure -->
|
||||
From SharepointToolbox/Views/Dialogs/ProfileManagementDialog.xaml:
|
||||
```xml
|
||||
<Window ... Width="500" Height="480" ResizeMode="NoResize">
|
||||
<Grid Margin="12">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" /> <!-- Row 0: Label "Profiles" -->
|
||||
<RowDefinition Height="*" /> <!-- Row 1: Profile ListBox -->
|
||||
<RowDefinition Height="Auto" /> <!-- Row 2: Input fields (Name/URL/ClientId) -->
|
||||
<RowDefinition Height="Auto" /> <!-- Row 3: Action buttons -->
|
||||
</Grid.RowDefinitions>
|
||||
...
|
||||
</Grid>
|
||||
</Window>
|
||||
```
|
||||
|
||||
<!-- Available converters from App.xaml -->
|
||||
- `{StaticResource Base64ToImageConverter}` — converts data URI string to BitmapImage
|
||||
- `{StaticResource StringToVisibilityConverter}` — Visible if non-empty, else Collapsed
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add client logo section and resize ProfileManagementDialog</name>
|
||||
<files>
|
||||
SharepointToolbox/Views/Dialogs/ProfileManagementDialog.xaml
|
||||
</files>
|
||||
<behavior>
|
||||
- Dialog height increases from 480 to 620 to accommodate the logo section
|
||||
- A new row (Row 3) is inserted between the input fields (Row 2) and buttons (now Row 4)
|
||||
- The client logo section contains:
|
||||
a) A labeled GroupBox "Client Logo" (localized)
|
||||
b) Inside: a Border with either an Image preview or placeholder text
|
||||
c) Three buttons: Import, Clear, Pull from Entra — horizontally aligned
|
||||
d) A TextBlock for ValidationMessage feedback
|
||||
- All logo controls are visually disabled when no profile is selected (via command CanExecute)
|
||||
- ValidationMessage shows success/error messages (already set by ViewModel commands)
|
||||
</behavior>
|
||||
<action>
|
||||
1. Edit `SharepointToolbox/Views/Dialogs/ProfileManagementDialog.xaml`:
|
||||
|
||||
a) Increase dialog height from 480 to 620:
|
||||
Change `Height="480"` to `Height="620"`
|
||||
|
||||
b) Add a new Row 3 for the logo section. Update RowDefinitions to:
|
||||
```xml
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" /> <!-- Row 0: Label -->
|
||||
<RowDefinition Height="*" /> <!-- Row 1: ListBox -->
|
||||
<RowDefinition Height="Auto" /> <!-- Row 2: Input fields -->
|
||||
<RowDefinition Height="Auto" /> <!-- Row 3: Client logo (NEW) -->
|
||||
<RowDefinition Height="Auto" /> <!-- Row 4: Buttons (was Row 3) -->
|
||||
</Grid.RowDefinitions>
|
||||
```
|
||||
|
||||
c) Move existing buttons StackPanel from Grid.Row="3" to Grid.Row="4"
|
||||
|
||||
d) Add the client logo section at Grid.Row="3":
|
||||
```xml
|
||||
<!-- Client Logo -->
|
||||
<StackPanel Grid.Row="3" Margin="0,8,0,8">
|
||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.title]}" Padding="0,0,0,4" />
|
||||
<Border BorderBrush="#DDDDDD" BorderThickness="1" Padding="8" CornerRadius="4"
|
||||
HorizontalAlignment="Left" MinWidth="200" MinHeight="50">
|
||||
<Grid>
|
||||
<Image Source="{Binding ClientLogoPreview, Converter={StaticResource Base64ToImageConverter}}"
|
||||
MaxHeight="60" MaxWidth="200" Stretch="Uniform" HorizontalAlignment="Left"
|
||||
Visibility="{Binding ClientLogoPreview, Converter={StaticResource StringToVisibilityConverter}}" />
|
||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.nopreview]}"
|
||||
VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||
Foreground="#999999" FontStyle="Italic">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Visibility" Value="Visible" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding ClientLogoPreview, Converter={StaticResource StringToVisibilityConverter}}" Value="Visible">
|
||||
<Setter Property="Visibility" Value="Collapsed" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
</Border>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.browse]}"
|
||||
Command="{Binding BrowseClientLogoCommand}" Width="80" Margin="0,0,8,0" />
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.clear]}"
|
||||
Command="{Binding ClearClientLogoCommand}" Width="80" Margin="0,0,8,0" />
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.autopull]}"
|
||||
Command="{Binding AutoPullClientLogoCommand}" Width="130" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding ValidationMessage}" Foreground="#CC0000" FontSize="11" Margin="0,4,0,0"
|
||||
Visibility="{Binding ValidationMessage, Converter={StaticResource StringToVisibilityConverter}}" />
|
||||
</StackPanel>
|
||||
```
|
||||
|
||||
Key decisions:
|
||||
- GroupBox replaced with Label + StackPanel for consistency with SettingsView pattern
|
||||
- Smaller preview (60px height vs 80px in Settings) because dialog has less space
|
||||
- Pull from Entra button is wider (130px) to fit localized text
|
||||
- ValidationMessage already set by Browse/Clear/AutoPull commands — just needs display
|
||||
- All three buttons auto-disable via ICommand.CanExecute when SelectedProfile is null
|
||||
</action>
|
||||
<verify>
|
||||
<automated>dotnet build --no-restore -warnaserror</automated>
|
||||
</verify>
|
||||
<done>ProfileManagementDialog shows client logo section with preview, three buttons, and feedback. Dialog resized. Build passes.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
dotnet build --no-restore -warnaserror
|
||||
```
|
||||
Build must pass with zero failures. Visual verification requires manual testing.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- ProfileManagementDialog.xaml has a visible Client Logo section between input fields and buttons
|
||||
- Image binds to ClientLogoPreview via Base64ToImageConverter
|
||||
- Placeholder text shows when no logo is configured
|
||||
- Import, Clear, and Pull from Entra buttons bind to existing ViewModel commands
|
||||
- All logo buttons disabled when no profile selected
|
||||
- ValidationMessage displays feedback when set
|
||||
- Dialog height increased to 620 to accommodate new section
|
||||
- Build passes with zero warnings
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/12-branding-ui-views/12-03-SUMMARY.md`
|
||||
</output>
|
||||
54
.planning/phases/12-branding-ui-views/12-RESEARCH.md
Normal file
54
.planning/phases/12-branding-ui-views/12-RESEARCH.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Phase 12 Research: Branding UI Views
|
||||
|
||||
## What Exists (Phase 11 Deliverables)
|
||||
|
||||
### SettingsViewModel (already complete)
|
||||
- `BrowseMspLogoCommand` (IAsyncRelayCommand) — opens file dialog, imports via IBrandingService, saves, updates preview
|
||||
- `ClearMspLogoCommand` (IAsyncRelayCommand) — clears via IBrandingService, nulls preview
|
||||
- `MspLogoPreview` (string?) — data URI format `data:{mime};base64,{b64}`, set on load and after browse/clear
|
||||
- `StatusMessage` — inherited from FeatureViewModelBase, set on error
|
||||
|
||||
### ProfileManagementViewModel (already complete)
|
||||
- `BrowseClientLogoCommand` — opens file dialog, imports, persists to profile
|
||||
- `ClearClientLogoCommand` — nulls ClientLogo, persists
|
||||
- `AutoPullClientLogoCommand` — fetches from Entra branding API, persists
|
||||
- `ValidationMessage` — set on error or success feedback
|
||||
- **GAP**: No `ClientLogoPreview` string property — SelectedProfile.ClientLogo is a LogoData object, NOT a data URI string. TenantProfile is not ObservableObject, so binding to SelectedProfile.ClientLogo won't notify UI on change.
|
||||
|
||||
### SettingsView.xaml (NO logo UI)
|
||||
- Current: Language combo + Data folder text+browse — that's it
|
||||
- Need: Add MSP logo section with Image preview, Browse, Clear buttons
|
||||
|
||||
### ProfileManagementDialog.xaml (NO logo UI)
|
||||
- Current: Profile ListBox, Name/URL/ClientId fields, Add/Rename/Delete/Close buttons
|
||||
- Window: 500x480, NoResize
|
||||
- Need: Add client logo section with Image preview, Browse, Clear, Auto-Pull buttons; resize dialog
|
||||
|
||||
## Infrastructure Gaps
|
||||
|
||||
### No Image Converter
|
||||
- `MspLogoPreview` is a data URI string — WPF `<Image Source=...>` does NOT natively bind to data URI strings
|
||||
- Need `Base64ToImageSourceConverter` IValueConverter: parse data URI → decode base64 → create BitmapImage from byte stream
|
||||
- Register in App.xaml as global resource
|
||||
|
||||
### Localization Keys Missing
|
||||
- No keys for logo UI labels/buttons in Strings.resx / Strings.fr.resx
|
||||
- Need: `settings.logo.msp`, `settings.logo.browse`, `settings.logo.clear`, `profile.logo.client`, `profile.logo.browse`, `profile.logo.clear`, `profile.logo.autopull`, `logo.nopreview`
|
||||
|
||||
## Available Patterns
|
||||
|
||||
### Converters
|
||||
- Live in `SharepointToolbox/Views/Converters/` (IndentConverter.cs has multiple converters)
|
||||
- Registered in App.xaml under `<Application.Resources>`
|
||||
- `StringToVisibilityConverter` already exists — can show/hide preview based on non-null string
|
||||
|
||||
### XAML Layout
|
||||
- SettingsView uses `<StackPanel>` with `<Separator>` between sections
|
||||
- ProfileManagementDialog uses `<Grid>` with row definitions
|
||||
- Buttons: `<Button Content="{Binding Source=...}" Command="{Binding ...}" Width="60" Margin="4,0" />`
|
||||
|
||||
## Plan Breakdown
|
||||
|
||||
1. **12-01**: Base64ToImageSourceConverter + localization keys + App.xaml registration + ClientLogoPreview ViewModel property
|
||||
2. **12-02**: SettingsView.xaml MSP logo section
|
||||
3. **12-03**: ProfileManagementDialog.xaml client logo section + dialog resize
|
||||
235
.planning/phases/13-user-directory-viewmodel/13-01-PLAN.md
Normal file
235
.planning/phases/13-user-directory-viewmodel/13-01-PLAN.md
Normal 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>
|
||||
529
.planning/phases/13-user-directory-viewmodel/13-02-PLAN.md
Normal file
529
.planning/phases/13-user-directory-viewmodel/13-02-PLAN.md
Normal 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>
|
||||
@@ -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 |
|
||||
73
.planning/phases/13-user-directory-viewmodel/13-RESEARCH.md
Normal file
73
.planning/phases/13-user-directory-viewmodel/13-RESEARCH.md
Normal 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
|
||||
Reference in New Issue
Block a user