From 07ed6e25159469cc121a2a1dc02e863bbbae51df Mon Sep 17 00:00:00 2001 From: Dev Date: Thu, 9 Apr 2026 13:09:38 +0200 Subject: [PATCH] feat(17-02): extend HtmlExportService with expandable group pills and toggleGroup JS - Add optional groupMembers parameter to both BuildHtml overloads and WriteAsync methods - SharePoint group pills render as expandable with onclick toggleGroup when groupMembers provided - Hidden member sub-rows injected after parent row with resolved member names - Empty member list renders 'members unavailable' fallback label - toggleGroup JS function added to inline script block in both overloads - filterTable updated to skip data-group sub-rows - CSS for .group-expandable added to both overloads - Backward compatibility: null groupMembers produces identical output to pre-Phase 17 --- .../Services/Export/HtmlExportService.cs | 104 ++++++++++++++++-- 1 file changed, 94 insertions(+), 10 deletions(-) diff --git a/SharepointToolbox/Services/Export/HtmlExportService.cs b/SharepointToolbox/Services/Export/HtmlExportService.cs index 3846977..1465e6c 100644 --- a/SharepointToolbox/Services/Export/HtmlExportService.cs +++ b/SharepointToolbox/Services/Export/HtmlExportService.cs @@ -14,8 +14,10 @@ public class HtmlExportService /// /// Builds a self-contained HTML string from the supplied permission entries. /// Includes inline CSS, inline JS filter, stats cards, type badges, unique/inherited badges, and user pills. + /// When is provided, SharePoint group pills become expandable. /// - public string BuildHtml(IReadOnlyList entries, ReportBranding? branding = null) + public string BuildHtml(IReadOnlyList entries, ReportBranding? branding = null, + IReadOnlyDictionary>? groupMembers = null) { // Compute stats var totalEntries = entries.Count; @@ -65,6 +67,8 @@ tr:hover td { background: #fafafa; } /* User pills */ .user-pill { display: inline-block; background: #e0e7ff; color: #3730a3; border-radius: 12px; padding: 2px 10px; font-size: .75rem; margin: 2px 3px 2px 0; white-space: nowrap; } .user-pill.external-user { background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; } +.group-expandable { cursor: pointer; } +.group-expandable:hover { opacity: 0.8; } a { color: #2563eb; text-decoration: none; } a:hover { text-decoration: underline; } "); @@ -96,6 +100,7 @@ a:hover { text-decoration: underline; } sb.AppendLine(""); sb.AppendLine(""); + int grpMemIdx = 0; foreach (var entry in entries) { var typeCss = ObjectTypeCss(entry.ObjectType); @@ -106,13 +111,41 @@ a:hover { text-decoration: underline; } var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries); var names = entry.Users.Split(';', StringSplitOptions.RemoveEmptyEntries); var pillsBuilder = new StringBuilder(); + var memberSubRows = new StringBuilder(); + for (int i = 0; i < logins.Length; i++) { var login = logins[i].Trim(); var name = i < names.Length ? names[i].Trim() : login; var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase); - var pillCss = isExt ? "user-pill external-user" : "user-pill"; - pillsBuilder.Append($"{HtmlEncode(name)}"); + + bool isExpandableGroup = entry.PrincipalType == "SharePointGroup" + && groupMembers != null + && groupMembers.TryGetValue(name, out var members); + + if (isExpandableGroup && groupMembers != null && groupMembers.TryGetValue(name, out var resolvedMembers)) + { + var grpId = $"grpmem{grpMemIdx}"; + pillsBuilder.Append($"{HtmlEncode(name)} ▼"); + + string memberContent; + if (resolvedMembers.Count > 0) + { + var memberParts = resolvedMembers.Select(m => $"{HtmlEncode(m.DisplayName)} <{HtmlEncode(m.Login)}>"); + memberContent = string.Join(" • ", memberParts); + } + else + { + memberContent = "members unavailable"; + } + memberSubRows.AppendLine($"{memberContent}"); + grpMemIdx++; + } + else + { + var pillCss = isExt ? "user-pill external-user" : "user-pill"; + pillsBuilder.Append($"{HtmlEncode(name)}"); + } } sb.AppendLine(""); @@ -124,6 +157,8 @@ a:hover { text-decoration: underline; } sb.AppendLine($" {HtmlEncode(entry.PermissionLevels)}"); sb.AppendLine($" {HtmlEncode(entry.GrantedThrough)}"); sb.AppendLine(""); + if (memberSubRows.Length > 0) + sb.Append(memberSubRows); } sb.AppendLine(""); @@ -136,8 +171,13 @@ a:hover { text-decoration: underline; } var input = document.getElementById('filter').value.toLowerCase(); var rows = document.querySelectorAll('#permTable tbody tr'); rows.forEach(function(row) { + if (row.hasAttribute('data-group')) return; row.style.display = row.textContent.toLowerCase().indexOf(input) > -1 ? '' : 'none'; }); +} +function toggleGroup(id) { + var rows = document.querySelectorAll('tr[data-group=""' + id + '""]'); + rows.forEach(function(r) { r.style.display = r.style.display === 'none' ? '' : 'none'; }); }"); sb.AppendLine(""); sb.AppendLine(""); @@ -149,9 +189,11 @@ a:hover { text-decoration: underline; } /// /// Writes the HTML report to the specified file path using UTF-8 without BOM. /// - public async Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct, ReportBranding? branding = null) + public async Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct, + ReportBranding? branding = null, + IReadOnlyDictionary>? groupMembers = null) { - var html = BuildHtml(entries, branding); + var html = BuildHtml(entries, branding, groupMembers); await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct); } @@ -168,8 +210,10 @@ a:hover { text-decoration: underline; } /// /// Builds a self-contained HTML string from simplified permission entries. /// Includes risk-level summary cards, color-coded rows, and simplified labels column. + /// When is provided, SharePoint group pills become expandable. /// - public string BuildHtml(IReadOnlyList entries, ReportBranding? branding = null) + public string BuildHtml(IReadOnlyList entries, ReportBranding? branding = null, + IReadOnlyDictionary>? groupMembers = null) { var summaries = PermissionSummaryBuilder.Build(entries); @@ -222,6 +266,8 @@ a:hover { text-decoration: underline; } .risk-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; border: 1px solid; } .user-pill { display: inline-block; background: #e0e7ff; color: #3730a3; border-radius: 12px; padding: 2px 10px; font-size: .75rem; margin: 2px 3px 2px 0; white-space: nowrap; } .user-pill.external-user { background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; } + .group-expandable { cursor: pointer; } + .group-expandable:hover { opacity: 0.8; } a { color: #2563eb; text-decoration: none; } a:hover { text-decoration: underline; } "); @@ -265,6 +311,7 @@ a:hover { text-decoration: underline; } sb.AppendLine(""); sb.AppendLine(""); + int grpMemIdx = 0; foreach (var entry in entries) { var typeCss = ObjectTypeCss(entry.ObjectType); @@ -275,13 +322,41 @@ a:hover { text-decoration: underline; } var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries); var names = entry.Inner.Users.Split(';', StringSplitOptions.RemoveEmptyEntries); var pillsBuilder = new StringBuilder(); + var memberSubRows = new StringBuilder(); + for (int i = 0; i < logins.Length; i++) { var login = logins[i].Trim(); var name = i < names.Length ? names[i].Trim() : login; var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase); - var pillCss = isExt ? "user-pill external-user" : "user-pill"; - pillsBuilder.Append($"{HtmlEncode(name)}"); + + bool isExpandableGroup = entry.Inner.PrincipalType == "SharePointGroup" + && groupMembers != null + && groupMembers.TryGetValue(name, out _); + + if (isExpandableGroup && groupMembers != null && groupMembers.TryGetValue(name, out var resolvedMembers)) + { + var grpId = $"grpmem{grpMemIdx}"; + pillsBuilder.Append($"{HtmlEncode(name)} ▼"); + + string memberContent; + if (resolvedMembers.Count > 0) + { + var memberParts = resolvedMembers.Select(m => $"{HtmlEncode(m.DisplayName)} <{HtmlEncode(m.Login)}>"); + memberContent = string.Join(" • ", memberParts); + } + else + { + memberContent = "members unavailable"; + } + memberSubRows.AppendLine($"{memberContent}"); + grpMemIdx++; + } + else + { + var pillCss = isExt ? "user-pill external-user" : "user-pill"; + pillsBuilder.Append($"{HtmlEncode(name)}"); + } } sb.AppendLine(""); @@ -295,6 +370,8 @@ a:hover { text-decoration: underline; } sb.AppendLine($" {HtmlEncode(entry.RiskLevel.ToString())}"); sb.AppendLine($" {HtmlEncode(entry.GrantedThrough)}"); sb.AppendLine(""); + if (memberSubRows.Length > 0) + sb.Append(memberSubRows); } sb.AppendLine(""); @@ -306,8 +383,13 @@ a:hover { text-decoration: underline; } var input = document.getElementById('filter').value.toLowerCase(); var rows = document.querySelectorAll('#permTable tbody tr'); rows.forEach(function(row) { + if (row.hasAttribute('data-group')) return; row.style.display = row.textContent.toLowerCase().indexOf(input) > -1 ? '' : 'none'; }); + } + function toggleGroup(id) { + var rows = document.querySelectorAll('tr[data-group=""' + id + '""]'); + rows.forEach(function(r) { r.style.display = r.style.display === 'none' ? '' : 'none'; }); }"); sb.AppendLine(""); sb.AppendLine(""); @@ -319,9 +401,11 @@ a:hover { text-decoration: underline; } /// /// Writes the simplified HTML report to the specified file path. /// - public async Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct, ReportBranding? branding = null) + public async Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct, + ReportBranding? branding = null, + IReadOnlyDictionary>? groupMembers = null) { - var html = BuildHtml(entries, branding); + var html = BuildHtml(entries, branding, groupMembers); await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct); }