14 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 17-group-expansion-html-reports | 02 | execute | 2 |
|
|
true |
|
|
Purpose: This is the user-visible feature — SharePoint group pills become clickable in HTML reports, expanding to show resolved members or a "members unavailable" fallback. Output: Modified HtmlExportService (both overloads), modified PermissionsViewModel, new HTML export tests.
<execution_context> @C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md @C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/17-group-expansion-html-reports/17-RESEARCH.md @.planning/phases/17-group-expansion-html-reports/17-01-SUMMARY.mdFrom SharepointToolbox/Core/Models/ResolvedMember.cs:
public record ResolvedMember(string DisplayName, string Login);
From SharepointToolbox/Services/ISharePointGroupResolver.cs:
public interface ISharePointGroupResolver
{
Task<IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>> ResolveGroupsAsync(
ClientContext ctx, string clientId,
IReadOnlyList<string> groupNames, CancellationToken ct);
}
From SharepointToolbox/Services/Export/HtmlExportService.cs:
public class HtmlExportService
{
// Overload 1 — standard PermissionEntry
public string BuildHtml(IReadOnlyList<PermissionEntry> entries, ReportBranding? branding = null)
public async Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct, ReportBranding? branding = null)
// Overload 2 — simplified
public string BuildHtml(IReadOnlyList<SimplifiedPermissionEntry> entries, ReportBranding? branding = null)
public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct, ReportBranding? branding = null)
}
From SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs:
public partial class PermissionsViewModel : FeatureViewModelBase
{
private readonly HtmlExportService? _htmlExportService;
private readonly ISessionManager _sessionManager;
private readonly IBrandingService? _brandingService;
private TenantProfile? _currentProfile;
public PermissionsViewModel(
IPermissionsService permissionsService,
ISiteListService siteListService,
ISessionManager sessionManager,
CsvExportService csvExportService,
HtmlExportService htmlExportService,
IBrandingService brandingService,
ILogger<FeatureViewModelBase> logger)
// Test constructor (no export services)
internal PermissionsViewModel(
IPermissionsService permissionsService,
ISiteListService siteListService,
ISessionManager sessionManager,
ILogger<FeatureViewModelBase> logger,
IBrandingService? brandingService = null)
private async Task ExportHtmlAsync()
{
// Currently calls: _htmlExportService.WriteAsync(entries, path, ct, branding)
}
}
From SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs (toggleGroup JS to copy):
function toggleGroup(id) {
var rows = document.querySelectorAll('tr[data-group="' + id + '"]');
rows.forEach(function(r) { r.style.display = r.style.display === 'none' ? '' : 'none'; });
}
public async Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath,
CancellationToken ct, ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
```
Same for the `SimplifiedPermissionEntry` overloads. `WriteAsync` passes `groupMembers` through to `BuildHtml`.
2. Add CSS for `.group-expandable` in the inline `<style>` block (both overloads):
```css
.group-expandable { cursor: pointer; }
.group-expandable:hover { opacity: 0.8; }
```
3. Modify the user-pill rendering loop in BOTH `BuildHtml` overloads. The logic:
- Track a `int grpMemIdx = 0` counter and a `StringBuilder memberSubRows` outside the foreach loop
- For each pill: check if `entry.PrincipalType == "SharePointGroup"` AND `groupMembers != null` AND `groupMembers.TryGetValue(name, out var members)`:
- YES with `members.Count > 0`: render `<span class="user-pill group-expandable" onclick="toggleGroup('grpmem{idx}')">Name ▼</span>` + append hidden member sub-row to `memberSubRows`
- YES with `members.Count == 0`: render same expandable pill + append sub-row with `<em style="color:#888">members unavailable</em>`
- NO (not in dict or groupMembers is null): render existing plain pill (no change)
- After `</tr>`, append `memberSubRows` content and clear it
- Member sub-row format: `<tr data-group="grpmem{idx}" style="display:none"><td colspan="7" style="padding-left:2em;font-size:.8rem;color:#555">Alice <alice@co.com> • Bob <bob@co.com></td></tr>`
4. Add `toggleGroup()` JS function to the inline `<script>` block (both overloads), right after `filterTable()`:
```javascript
function toggleGroup(id) {
var rows = document.querySelectorAll('tr[data-group="' + id + '"]');
rows.forEach(function(r) { r.style.display = r.style.display === 'none' ? '' : 'none'; });
}
```
NOTE: Also update the `filterTable()` function to skip hidden member sub-rows (rows with `data-group` attribute) so they are not shown/hidden by the text filter. Add a guard: `if (row.hasAttribute('data-group')) return;` at the start of the forEach callback. Use `rows.forEach(function(row) { if (row.hasAttribute('data-group')) return; ... })`.
5. Write tests in `HtmlExportServiceTests.cs`:
- `BuildHtml_NoGroupMembers_IdenticalToDefault`: call BuildHtml(entries) and BuildHtml(entries, null, null) — output must match
- `BuildHtml_WithGroupMembers_RendersExpandablePill`: create a PermissionEntry with PrincipalType="SharePointGroup" and Users="Site Members", pass groupMembers dict with "Site Members" -> [ResolvedMember("Alice", "alice@co.com")], assert output contains `onclick="toggleGroup('grpmem0')"` and `class="user-pill group-expandable"`
- `BuildHtml_WithGroupMembers_RendersHiddenMemberSubRow`: same setup, assert output contains `data-group="grpmem0"` and `display:none` and `Alice` and `alice@co.com`
- `BuildHtml_WithEmptyMemberList_RendersMembersUnavailable`: pass groupMembers with "Site Members" -> empty list, assert output contains `members unavailable`
- `BuildHtml_ContainsToggleGroupJs`: assert output contains `function toggleGroup`
- `BuildHtml_Simplified_WithGroupMembers_RendersExpandablePill`: same test for the simplified overload
dotnet test --filter "FullyQualifiedName~HtmlExportServiceTests" --no-build
Both BuildHtml overloads render expandable group pills with toggleGroup JS, backward compatibility preserved when groupMembers is null, 6+ new tests pass
Task 2: Wire PermissionsViewModel to call ISharePointGroupResolver before HTML export
SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs
1. Add `ISharePointGroupResolver?` field and inject it via constructor:
- Add `private readonly ISharePointGroupResolver? _groupResolver;` field
- Main constructor: add `ISharePointGroupResolver? groupResolver = null` as the LAST parameter (optional so existing DI still works if resolver not registered yet — but it will be registered from Plan 01)
- Assign `_groupResolver = groupResolver;`
- Test constructor: no change needed (resolver is null by default = no group expansion in tests)
2. Modify `ExportHtmlAsync()` to resolve groups before export:
```csharp
// After branding resolution, before WriteAsync calls:
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null;
if (_groupResolver != null && Results.Count > 0)
{
var groupNames = Results
.Where(r => r.PrincipalType == "SharePointGroup")
.SelectMany(r => r.Users.Split(';', StringSplitOptions.RemoveEmptyEntries))
.Select(n => n.Trim())
.Where(n => n.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (groupNames.Count > 0 && _currentProfile != null)
{
try
{
var ctx = await _sessionManager.GetOrCreateContextAsync(
_currentProfile, CancellationToken.None);
groupMembers = await _groupResolver.ResolveGroupsAsync(
ctx, _currentProfile.ClientId, groupNames, CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Group resolution failed — exporting without member expansion.");
}
}
}
```
3. Update both WriteAsync call sites to pass `groupMembers`:
```csharp
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None, branding, groupMembers);
else
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding, groupMembers);
```
4. Add `using SharepointToolbox.Core.Models;` if not already present (for `ResolvedMember`).
dotnet build --no-restore 2>&1 | tail -5
PermissionsViewModel collects group names from Results, calls ISharePointGroupResolver, passes resolved dict to HtmlExportService.WriteAsync for both standard and simplified paths, gracefully handles resolution failure
- `dotnet build` — 0 errors, 0 warnings
- `dotnet test --filter "FullyQualifiedName~HtmlExportServiceTests"` — all tests pass (including 6+ new group expansion tests)
- `dotnet test` — full suite green (no regressions)
- HTML output with groupMembers=null is identical to pre-Phase 17 output
- HTML output with groupMembers contains clickable group pills and hidden member sub-rows
<success_criteria>
- HtmlExportService both overloads accept optional groupMembers parameter
- Group pills render as expandable with toggleGroup JS when groupMembers is provided
- Empty member lists show "members unavailable" label
- Null/missing groupMembers preserves exact pre-Phase 17 output
- PermissionsViewModel resolves groups before export and passes dict to service
- All new tests green, full suite green </success_criteria>