Public/Add-DhTreeFilter.ps1
|
function Add-DhTreeFilter { <# .SYNOPSIS Add a collapsible tree that filters a table by path (an OU / folder navigator). .DESCRIPTION v2.4.0. Shows a hierarchy (organisational units, folders, sites) as an expandable tree with a find box and a count per node. Choosing a node filters the linked table to the rows whose path field is that node or below it. Choosing it again, or using the table's clear button, removes the filter. The filter composes (AND) with the table's text filter, card filters and global filters. The tree is a filter, not a second copy of the data: the objects stay in the table. A node can carry a Detail (any section spec, see Add-DhKeyValue) that opens in the side panel. Matching is case-insensitive and respects the separator, so 'HQ' never matches 'HQ2': -Match Prefix paths read root-first, 'contoso.com/HQ/Users' (separator '/') -Match Suffix distinguished names, 'OU=Users,OU=HQ,DC=contoso' (separator ',') .PARAMETER Report Dashboard object from New-DhDashboard. .PARAMETER Id Unique block identifier (alphanumeric, - or _). .PARAMETER Title Heading shown above the tree. .PARAMETER TableId The table to filter. It must already be added. .PARAMETER PathField The row property that holds the path / distinguished name. .PARAMETER Nodes Array of hashtables: @{ Id='hq'; Parent='root'; Label='HQ'; Path='contoso.com/HQ' Count=42; Status='ok'; Flags=@('empty'); Detail=@{...} } Id, Label and Path are required; Parent is empty for a root. Count is optional (see -CountFromTable). Status is ok | warn | danger | info | nodata. Flags are short labels shown as chips. Detail is a section spec. .PARAMETER Match Prefix (default) or Suffix. .PARAMETER Separator Path separator. Default '/' for Prefix, ',' for Suffix. .PARAMETER CountFromTable Count the table's rows under each node in the browser (rows at the node plus everything below it) instead of using each node's Count. Counts ignore the table's other filters. .PARAMETER MaxNodes Warn above this many nodes (default 5000); throw above 20000. .PARAMETER Height Tree viewport height in px (default 420). .PARAMETER DefaultOpenDepth Levels that start expanded (default 1: the roots are open, so their children show; 0: only the roots are shown; 99: everything is open). .EXAMPLE Add-DhTreeFilter -Report $r -Id 'ou-tree' -Title 'Organisational units' ` -TableId 'ous' -PathField 'CanonicalName' -CountFromTable -Nodes @( @{ Id='root'; Label='contoso.com'; Path='contoso.com' } @{ Id='hq'; Parent='root'; Label='HQ'; Path='contoso.com/HQ' } @{ Id='srv'; Parent='hq'; Label='Servers'; Path='contoso.com/HQ/Servers'; Flags=@('empty') } ) #> [CmdletBinding()] param( [Parameter(Mandatory)] [System.Collections.Specialized.OrderedDictionary] $Report, [Parameter(Mandatory)] [ValidatePattern('^[A-Za-z0-9_-]+$')] [string] $Id, [Parameter(Mandatory)] [string] $Title, [Parameter(Mandatory)] [string] $TableId, [Parameter(Mandatory)] [string] $PathField, [Parameter(Mandatory)] [object[]] $Nodes, [ValidateSet('Prefix','Suffix')] [string] $Match = 'Prefix', [string] $Separator = '', [switch] $CountFromTable, [ValidateRange(1, 100000)] [int] $MaxNodes = 5000, [ValidateRange(120, 1200)] [int] $Height = 420, [ValidateRange(0, 99)] [int] $DefaultOpenDepth = 1, [bool] $Collapsible = $false, [bool] $DefaultOpen = $true, [ValidateRange(0,12)] [int] $GridSpan = 0, [string] $NavGroup = '', [string] $NavSubGroup = '', # ---- v2.0.0 navigation (see readme-dev.md section 5) ---- [string] $NavPath = '', [ValidateSet('','Exact','Subtree','Global')] [string] $NavScope = '', [string] $NavLabel = '', [int] $NavOrder = 0 ) $navDecl = Resolve-DhNavDeclaration -NavPath $NavPath -NavScope $NavScope ` -NavGroup $NavGroup -NavSubGroup $NavSubGroup ` -ItemKind 'Block' -Context 'Add-DhTreeFilter' if (-not $Report.Contains('_DhNavSeq')) { $Report['_DhNavSeq'] = 0 } $navSeqNo = [int]$Report['_DhNavSeq']; $Report['_DhNavSeq'] = $navSeqNo + 1 if (-not $Report.Contains('Blocks')) { $Report['Blocks'] = [System.Collections.Generic.List[hashtable]]::new() } foreach ($existing in $Report.Blocks) { if ($existing.Id -eq $Id) { throw "Add-DhTreeFilter: A block with Id '$Id' already exists in this report. Use a unique Id." } } $srcTable = @($Report.Tables | Where-Object { $_.Id -eq $TableId } | Select-Object -First 1) if ($srcTable.Count -eq 0) { throw "Add-DhTreeFilter '$Id': table '$TableId' not found. Add the table before the tree filter." } $fieldNames = Get-DhTableFieldNames -Table $srcTable[0] if ($PathField -cnotin $fieldNames) { throw "Add-DhTreeFilter '$Id': PathField '$PathField' is not a field of table '$TableId'. Fields: $($fieldNames -join ', ')." } if (@($Nodes).Count -lt 1) { throw "Add-DhTreeFilter '$Id': -Nodes must contain at least one node." } if (@($Nodes).Count -gt 20000) { throw "Add-DhTreeFilter '$Id': $(@($Nodes).Count) nodes is over the hard limit of 20000. Send the top levels only." } if (@($Nodes).Count -gt $MaxNodes) { Write-Warning "Add-DhTreeFilter '$Id': $(@($Nodes).Count) nodes exceeds -MaxNodes ($MaxNodes). The tree is embedded in the page; consider the top levels only." } $sep = if ($Separator) { $Separator } elseif ($Match -eq 'Suffix') { ',' } else { '/' } $allowedStatus = @('ok','warn','danger','info','nodata','') $ids = @{}; $paths = @{} $norm = foreach ($n in $Nodes) { if ($n -isnot [System.Collections.IDictionary]) { throw "Add-DhTreeFilter '$Id': each node must be a hashtable. Got: $($n.GetType().Name)" } foreach ($req in 'Id','Label','Path') { if (-not $n.Contains($req) -or [string]::IsNullOrWhiteSpace([string]$n[$req])) { throw "Add-DhTreeFilter '$Id': each node must have a non-empty '$req' key." } } $nid = [string]$n['Id'] if ($ids.ContainsKey($nid)) { throw "Add-DhTreeFilter '$Id': duplicate node Id '$nid'." } $ids[$nid] = $true $path = [string]$n['Path'] $pk = $path.ToLowerInvariant() if ($paths.ContainsKey($pk)) { throw "Add-DhTreeFilter '$Id': node '$nid' repeats Path '$path' (already used by '$($paths[$pk])')." } $paths[$pk] = $nid $status = if ($n.Contains('Status') -and $n['Status']) { ConvertTo-DhStatusName $n['Status'] } else { '' } $status = switch ($status) { 'warning' { 'warn' } 'critical' { 'danger' } 'error' { 'danger' } default { $status } } if ($status -notin $allowedStatus) { throw "Add-DhTreeFilter '$Id': node '$nid' Status must be ok, warn, danger, info or nodata. Got: $($n['Status'])" } $count = $null if ($n.Contains('Count') -and $null -ne $n['Count'] -and "$($n['Count'])" -ne '') { $c = 0L if (-not [long]::TryParse([string]$n['Count'], [System.Globalization.NumberStyles]::Integer, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$c) -or $c -lt 0) { throw "Add-DhTreeFilter '$Id': node '$nid' Count must be a whole number >= 0. Got: $($n['Count'])" } $count = $c } $flags = @(if ($n.Contains('Flags') -and $null -ne $n['Flags']) { @($n['Flags']) | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | ForEach-Object { [string]$_ } }) [ordered]@{ Id = $nid Parent = if ($n.Contains('Parent') -and -not [string]::IsNullOrWhiteSpace([string]$n['Parent'])) { [string]$n['Parent'] } else { '' } Label = [string]$n['Label'] Path = $path Count = $count Status = $status Flags = $flags Detail = if ($n.Contains('Detail')) { ConvertTo-DhDetailSpec -Detail $n['Detail'] -Context "Add-DhTreeFilter '$Id' node '$nid'" } else { $null } } } $norm = @($norm) # Parents exist; walk up with a hashtable (no pipeline per step) to find cycles. $byId = @{} foreach ($n in $norm) { $byId[$n.Id] = $n } foreach ($n in $norm) { if ($n.Parent -and -not $byId.ContainsKey($n.Parent)) { throw "Add-DhTreeFilter '$Id': node '$($n.Id)' references unknown Parent '$($n.Parent)'." } if ($n.Id -eq $n.Parent) { throw "Add-DhTreeFilter '$Id': node '$($n.Id)' cannot be its own Parent." } } foreach ($n in $norm) { $seen = @{ ($n.Id) = $true } $cur = $n.Parent; $depth = 0 while ($cur -and $depth -lt 100000) { if ($seen.ContainsKey($cur)) { throw "Add-DhTreeFilter '$Id': cycle detected at node '$cur' (in chain starting from '$($n.Id)')." } $seen[$cur] = $true $cur = $byId[$cur].Parent $depth++ } } $Report.Blocks.Add([ordered]@{ BlockType = 'treefilter' Id = $Id Title = $Title TableId = $TableId PathField = $PathField Nodes = $norm Match = $Match Separator = $sep CountFromTable = [bool]$CountFromTable.IsPresent Height = $Height DefaultOpenDepth = $DefaultOpenDepth NavGroup = $NavGroup NavSubGroup = $NavSubGroup NavPath = $NavPath NavSegments = $navDecl.Path NavSeqNo = $navSeqNo NavScope = $navDecl.Scope NavLabel = $NavLabel NavOrder = $NavOrder GridSpan = $GridSpan Collapsible = $Collapsible DefaultOpen = $DefaultOpen }) Write-Verbose "Add-DhTreeFilter: '$Id' ($($norm.Count) node(s)) filters table '$TableId' on '$PathField' ($Match)." } |