Files
Sharepoint-Toolbox/.planning/phases/17-group-expansion-html-reports/17-02-PLAN.md
2026-04-09 12:59:12 +02:00

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
17-01
SharepointToolbox/Services/Export/HtmlExportService.cs
SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs
SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs
true
RPT-01
RPT-02
truths artifacts key_links
SharePoint group pills in the HTML report are clickable and expand to show group members
When group members are resolved, clicking the pill reveals member names inline
When group resolution fails, the pill expands to show 'members unavailable' label
When no groupMembers dict is passed, HTML output is identical to pre-Phase 17 output
toggleGroup() JS function exists in HtmlExportService inline JS
PermissionsViewModel calls ISharePointGroupResolver before HTML export and passes results to BuildHtml
path provides contains
SharepointToolbox/Services/Export/HtmlExportService.cs Expandable group pill rendering + toggleGroup JS toggleGroup
path provides contains
SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs Group resolution orchestration before export _groupResolver
path provides contains
SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs Tests for group pill expansion and backward compatibility grpmem
from to via pattern
SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs ISharePointGroupResolver constructor injection + call in ExportHtmlAsync _groupResolver.ResolveGroupsAsync
from to via pattern
SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs HtmlExportService.BuildHtml passing groupMembers dict groupMembers
from to via pattern
SharepointToolbox/Services/Export/HtmlExportService.cs toggleGroup JS inline script block function toggleGroup
Wire group member expansion into HtmlExportService rendering and PermissionsViewModel export flow.

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.md

From 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'; });
}
Task 1: Extend HtmlExportService with groupMembers parameter and expandable group pills SharepointToolbox/Services/Export/HtmlExportService.cs, SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs - RPT-01-a: BuildHtml with no groupMembers (null/omitted) produces output identical to pre-Phase 17 - RPT-01-b: BuildHtml with groupMembers containing a group name renders clickable pill with onclick="toggleGroup('grpmem0')" and class "group-expandable" - RPT-01-c: BuildHtml with resolved members renders hidden sub-row (data-group="grpmem0", display:none) containing member display names - RPT-01-d: BuildHtml with empty member list (resolution failed) renders sub-row with "members unavailable" italic label - RPT-01-e: BuildHtml output contains "function toggleGroup" in inline JS - RPT-01-f: Simplified BuildHtml overload also accepts groupMembers and renders expandable pills identically 1. Add `groupMembers` optional parameter to BOTH `BuildHtml` overloads and BOTH `WriteAsync` methods: ```csharp public string BuildHtml(IReadOnlyList entries, ReportBranding? branding = null, IReadOnlyDictionary>? groupMembers = null)
   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 &#9660;</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 &lt;alice@co.com&gt; &bull; Bob &lt;bob@co.com&gt;</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>
After completion, create `.planning/phases/17-group-expansion-html-reports/17-02-SUMMARY.md`