mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 10:55:38 +02:00
1163 lines
50 KiB
PowerShell
1163 lines
50 KiB
PowerShell
# Avalonia port of UI/WPF/Extensions/IntuneManagerUI.ps1.
|
|
#
|
|
# Slice 2 + Slice 3a + Slice 3b1+3b2 + Slice 3c + Slice 3d1+3d2+3d3 +
|
|
# Slice 3e + Slice 3f + Slice 4a + Slice 4b + Slice 4c + Slice 4d +
|
|
# Slice 4e + Slice 4f: shell + activation + real data load + dynamic
|
|
# DataGrid columns + filter/refresh + paging + Delete + Copy (incl.
|
|
# scope-tag dual-list) + Export (without per-policy-type UI extensions) +
|
|
# Compare (Intune-vs-Intune AND file compare, with Match-based row
|
|
# coloring + Save/Copy output) + Import + View (Object Details — Json /
|
|
# Settings / Columns tabs, without per-policy-type AddUIDetailsExtension
|
|
# hooks) + Bulk Export + Bulk Import + Bulk Delete + Bulk Scope Tags +
|
|
# Bulk Assignments + Bulk Compare. All bulk forms ported.
|
|
# Copy, Export, Compare, Import,
|
|
# View, Bulk Export, Bulk Import, Bulk Delete, Bulk Scope Tags, Bulk
|
|
# Assignments, and Bulk Compare live in their own files
|
|
# (Extensions/IntuneManagerCopyUI.ps1, IntuneManagerExportUI.ps1,
|
|
# IntuneManagerCompareUI.ps1, IntuneManagerImportUI.ps1,
|
|
# IntuneManagerDetailsUI.ps1, IntuneManagerBulkExportUI.ps1,
|
|
# IntuneManagerBulkImportUI.ps1, IntuneManagerBulkDeleteUI.ps1,
|
|
# IntuneManagerBulkScopeTagUI.ps1, IntuneManagerBulkAssignmentsUI.ps1,
|
|
# IntuneManagerBulkCompareUI.ps1) per architecture R9.
|
|
|
|
Add-AppEvent "IntuneManagerUISelectedMenuItemChanged"
|
|
Add-AppEvent "IntuneManagerUISelectedItemChanged"
|
|
|
|
Add-AppEventHandler "AppInitialized" "Invoke-IntuneUIAppInitialized"
|
|
Add-AppEventHandler "SettingsUpdated" "Invoke-IntuneUIEventSettingsUpdated"
|
|
|
|
function Get-IntuneViewItems
|
|
{
|
|
[OutputType([Object[]])]
|
|
param()
|
|
|
|
$viewType = Get-SettingValue "ObjectViewType"
|
|
if ($viewType -eq "Type") {
|
|
$viewItems = $script:IntuneTypes | Sort-Object -Property Title
|
|
} else {
|
|
$viewItems = $script:IntuneGroups | Sort-Object -Property Title
|
|
}
|
|
|
|
# Read-only "Intune Info" group (Baseline Templates, Tenant Settings, etc.) is
|
|
# opt-in: hidden unless enabled via the gear menu's "Show read-only Info". Drop
|
|
# the group (Group view) or its member types (API view).
|
|
if ((Get-SettingStoreValue "IntuneManager" "ShowIntuneInfo" "false") -ne "true") {
|
|
if ($viewType -eq "Type") {
|
|
$viewItems = @($viewItems | Where-Object { -not ($_.PolicyGroup -and $_.PolicyGroup.Id -eq "IntuneInfo") })
|
|
} else {
|
|
$viewItems = @($viewItems | Where-Object { $_.Id -ne "IntuneInfo" })
|
|
}
|
|
}
|
|
|
|
# Stamp AccessType / AccessInfo from the token's granted scopes so restricted
|
|
# rows can be coloured (orange = read-only, red = no access). Groups
|
|
# aggregate their member types. No-op when there is no token to diff against.
|
|
Update-IntuneAccessLevels
|
|
|
|
if ((Get-SettingValue 'HideNoAccess')) {
|
|
# Drop rows the token cannot use at all. Groups aggregate to None only
|
|
# when every member type is unusable, so a partially usable group is
|
|
# never hidden - it stays visible and orange.
|
|
$viewItems = @($viewItems | Where-Object { $_.AccessType -ne [APIAccess]::None })
|
|
}
|
|
|
|
# Icons are parsed here, after the splash is up, not at module import.
|
|
Initialize-MenuItemIcons -Items $viewItems
|
|
|
|
# Avalonia's binder needs real CLR properties for the {Binding MenuLabel}
|
|
# in the lstMenuItems DataTemplate — Add-Member NoteProperty silently fails.
|
|
# Wrap each underlying type in a ViewMenuItem and stash the original on Tag
|
|
# so OnItemChanged can recover it.
|
|
$wrapped = foreach ($item in $viewItems) {
|
|
if ($null -eq $item) { continue }
|
|
$access = [string]$item.AccessType
|
|
[ViewMenuItem]@{
|
|
Id = [string]$item.ID
|
|
Title = [string]$item.Title
|
|
MenuLabel = [string]$item.Title
|
|
IconImage = $item.IconImage
|
|
AccessType = $access
|
|
# $null rather than '' so rows with nothing to say get no tooltip.
|
|
AccessInfo = if ($item.AccessInfo) { [string]$item.AccessInfo } else { $null }
|
|
IsAccessLimited = ($access -eq 'Limited')
|
|
IsAccessNone = ($access -eq 'None')
|
|
Tag = $item
|
|
}
|
|
}
|
|
|
|
return @($wrapped)
|
|
}
|
|
|
|
function Get-IntuneManagementViewPanel
|
|
{
|
|
$ui = $script:UIProvider
|
|
$panel = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/IntuneManagerPanel.axaml'))
|
|
if (-not $panel) { return $null }
|
|
|
|
$hostType = Get-AvaloniaHost
|
|
$script:dgIntuneManagerObjects = $hostType::FindByName($panel, 'dgIntuneManagerObjects')
|
|
$script:spIntuneManagerButtons = $hostType::FindByName($panel, 'spSubMenu')
|
|
$script:IntuneManagementFilterTextBox = $hostType::FindByName($panel, 'txtFilter')
|
|
$script:IntuneManagementObjectsCount = $hostType::FindByName($panel, 'txtObjectsCount')
|
|
$script:grdIntuneManagerNotLoggedIn = $hostType::FindByName($panel, 'grdNotLoggedIn')
|
|
|
|
$ui.AddXamlEvent($panel, 'btnView', 'Add_Click', ({ Show-IntuneManagerDetailedView }))
|
|
$ui.AddXamlEvent($panel, 'btnCompare', 'Add_Click', ({ Invoke-IntuneManagerCompare }))
|
|
$ui.AddXamlEvent($panel, 'btnCopy', 'Add_Click', ({ Copy-IntuneManagerPolicy }))
|
|
$ui.AddXamlEvent($panel, 'btnDelete', 'Add_Click', ({ Remove-GraphObjectsUI }))
|
|
$ui.AddXamlEvent($panel, 'btnImport', 'Add_Click', ({ Show-IntuneManagerImportForm }))
|
|
$ui.AddXamlEvent($panel, 'btnExport', 'Add_Click', ({ Show-IntuneManagerExportForm }))
|
|
$ui.AddXamlEvent($panel, 'btnDocument', 'Add_Click', ({
|
|
# Prefer IsSelected-checked rows; fall back to highlighted; fall back
|
|
# to all visible. Scans run over the grid's CURRENT (filtered)
|
|
# ItemsSource, never the unfiltered backing list - a checked row that
|
|
# the filter hides must not be acted on.
|
|
$visibleRows = @($script:dgIntuneManagerObjects.ItemsSource)
|
|
$items = @($visibleRows | Where-Object { $_.IsSelected } | ForEach-Object { $_.Source } | Where-Object { $_ })
|
|
if ($items.Count -eq 0 -and $script:dgIntuneManagerObjects.SelectedItem) {
|
|
$row = $script:dgIntuneManagerObjects.SelectedItem
|
|
$candidate = if ($row.PSObject.Properties['Source']) { $row.Source } else { $row }
|
|
if ($candidate) { $items = @($candidate) }
|
|
}
|
|
if ($items.Count -eq 0) {
|
|
$items = @($visibleRows | ForEach-Object { $_.Source } | Where-Object { $_ })
|
|
}
|
|
if ($items.Count -eq 0) { return }
|
|
Show-IntuneManagerDocumentForm -PolicyObject $items
|
|
}))
|
|
$ui.AddXamlEvent($panel, 'btnRefresh', 'Add_Click', ({ Invoke-RefreshObjects }))
|
|
|
|
$ui.AddXamlEvent($panel, 'btnLoadAllPages', 'Add_Click', ({ Add-GraphPoliciesFromPaging 'AllRemainingPages' }))
|
|
$ui.AddXamlEvent($panel, 'btnLoadNextPage', 'Add_Click', ({ Add-GraphPoliciesFromPaging 'NextPage' }))
|
|
|
|
$ui.AddXamlEvent($panel, 'txtFilter', 'Add_TextChanged', ({
|
|
Invoke-FilterBoxChanged $script:IntuneManagementFilterTextBox `
|
|
$script:dgIntuneManagerObjects `
|
|
-ObjectsCountTextBox $script:IntuneManagementObjectsCount
|
|
}))
|
|
|
|
if ($script:dgIntuneManagerObjects) {
|
|
$script:dgIntuneManagerObjects.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
|
|
Update-IntuneManagerActionButtons
|
|
}))
|
|
}
|
|
|
|
return $panel
|
|
}
|
|
|
|
function Invoke-IntuneManagerNotPorted
|
|
{
|
|
param([string]$Action)
|
|
$ui = $script:UIProvider
|
|
$ui.ShowMessageBox("$Action is not yet ported to the Avalonia backend.", "Not yet ported")
|
|
}
|
|
|
|
function Update-IntuneManagerActionButtons
|
|
{
|
|
if (-not $script:dgIntuneManagerObjects -or -not $script:spIntuneManagerButtons) { return }
|
|
|
|
$items = $script:dgIntuneManagerObjects.ItemsSource
|
|
$count = if ($items) { @($items).Count } else { 0 }
|
|
# WPF enables the action buttons when rows are CHECKED as well as when a
|
|
# row is highlighted. Without the checked clause, ticking rows (including
|
|
# select-all) left every button disabled.
|
|
#
|
|
# This runs on every SelectionChanged, so only ask whether ANY row is
|
|
# checked - a Where-Object pass over the whole ItemsSource stutters keyboard
|
|
# navigation once Load All has pulled in thousands of rows. A highlighted
|
|
# row short-circuits the scan entirely (the common click case).
|
|
$hasSelectedItems = $false
|
|
if ($count -gt 0) {
|
|
if ($null -ne $script:dgIntuneManagerObjects.SelectedItem) {
|
|
$hasSelectedItems = $true
|
|
}
|
|
else {
|
|
foreach ($row in $items) {
|
|
if ($row.IsSelected) { $hasSelectedItems = $true; break }
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach ($name in 'btnView','btnCompare','btnCopy','btnDelete') {
|
|
$btn = (Get-AvaloniaHost)::FindByName($script:spIntuneManagerButtons, $name)
|
|
if ($btn) { $btn.IsEnabled = $hasSelectedItems }
|
|
}
|
|
$btnExport = (Get-AvaloniaHost)::FindByName($script:spIntuneManagerButtons, 'btnExport')
|
|
if ($btnExport) { $btnExport.IsEnabled = $hasSelectedItems }
|
|
# btnDocument stays enabled whenever any rows are loaded — clicking with
|
|
# nothing selected documents the whole view (matches OLD project's UX).
|
|
$btnDocument = (Get-AvaloniaHost)::FindByName($script:spIntuneManagerButtons, 'btnDocument')
|
|
if ($btnDocument) { $btnDocument.IsEnabled = ($count -gt 0) }
|
|
|
|
Invoke-AppEvent "IntuneManagerUISelectedItemChanged" $hasSelectedItems
|
|
}
|
|
|
|
|
|
function Invoke-IntuneActivatingView
|
|
{
|
|
param($PreviousActiveView)
|
|
|
|
Add-GraphBulkMenu
|
|
|
|
if ($null -eq $PreviousActiveView) {
|
|
# WPF parity routes this through the splash window — no splash in
|
|
# Avalonia, so write to the status overlay and clear it once
|
|
# silent-auth finishes (success surfaces via AuthenticatedNewToken;
|
|
# failure / no-op leaves us on the not-logged-in overlay).
|
|
Write-Status "Authenticating"
|
|
try {
|
|
$obj = Get-SingletonObject "IntuneViewObject"
|
|
if ($obj) { $obj.Authenticate() }
|
|
} finally {
|
|
Write-Status ""
|
|
}
|
|
}
|
|
}
|
|
|
|
function Invoke-IntuneDeactivatingView
|
|
{
|
|
param($NewActiveView)
|
|
|
|
if ($script:mnuMain) {
|
|
$tmp = $script:mnuMain.Items | Where-Object { $_.Name -eq 'IntuneBulk' }
|
|
if ($tmp) { $script:mnuMain.Items.Remove($tmp) | Out-Null }
|
|
}
|
|
}
|
|
|
|
function Add-GraphBulkMenu
|
|
{
|
|
if (-not $script:mnuMain) { return }
|
|
|
|
# Skip if already added (re-activations).
|
|
$existing = $script:mnuMain.Items | Where-Object { $_.Name -eq 'IntuneBulk' }
|
|
if ($existing) { return }
|
|
|
|
$menuItem = New-Object Avalonia.Controls.MenuItem
|
|
$menuItem.Header = '_Bulk'
|
|
$menuItem.Name = 'IntuneBulk'
|
|
|
|
# Item order matches the original project: Export, Import, Delete, Compare,
|
|
# Copy, Documentation. Features that are new in this version (Scope Tags,
|
|
# Assignments) are appended after a separator.
|
|
$bulkActions = @(
|
|
@{ Header = '_Export'; Action = { Show-GraphBulkExportForm } }
|
|
@{ Header = '_Import'; Action = { Show-GraphBulkImportForm } }
|
|
@{ Header = '_Delete'; Action = { Show-GraphBulkDeleteForm }; Name = 'mnuBulkDelete' }
|
|
@{ Header = 'C_ompare'; Action = { Show-GraphBulkCompareForm } }
|
|
@{ Header = 'Cop_y'; Action = { Show-GraphBulkCopyForm } }
|
|
@{ Header = 'Doc_umentation'; Action = { Show-GraphBulkDocumentationForm } }
|
|
@{ Separator = $true }
|
|
@{ Header = '_Scope Tags'; Action = { Show-GraphBulkScopeTagForm } }
|
|
@{ Header = '_Assignments'; Action = { Show-GraphBulkAssignmentsForm } }
|
|
)
|
|
|
|
foreach ($entry in $bulkActions) {
|
|
if ($entry.Separator) {
|
|
$menuItem.Items.Add((New-Object Avalonia.Controls.Separator)) | Out-Null
|
|
continue
|
|
}
|
|
$sub = New-Object Avalonia.Controls.MenuItem
|
|
$sub.Header = $entry.Header
|
|
if ($entry.Name) { $sub.Name = $entry.Name }
|
|
$sub.add_Click((ConvertTo-AvaloniaEventScriptBlock $entry.Action))
|
|
if ($entry.Name -eq 'mnuBulkDelete') {
|
|
$sub.IsVisible = ((Get-SettingValue 'AllowBulkDelete') -eq $true)
|
|
}
|
|
$menuItem.Items.Add($sub) | Out-Null
|
|
}
|
|
|
|
Add-MenuItem $menuItem 1
|
|
}
|
|
|
|
function Invoke-IntuneActivateObject
|
|
{
|
|
param($SelectedItem)
|
|
|
|
$payload = $null
|
|
if ($SelectedItem -is [ViewMenuItem]) {
|
|
$payload = $SelectedItem.Tag
|
|
} else {
|
|
$payload = $SelectedItem
|
|
}
|
|
|
|
if ($null -eq $payload) {
|
|
if ($script:dgIntuneManagerObjects) {
|
|
$script:dgIntuneManagerObjects.ItemsSource = $null
|
|
}
|
|
return $null
|
|
}
|
|
|
|
Write-Status "Activate $($payload.Title)"
|
|
$script:IntuneManagerSelectedObject = $payload
|
|
|
|
Clear-GraphObjects
|
|
|
|
Show-SelectedGraphPolicies -SinglePage -Restart
|
|
return $null
|
|
}
|
|
|
|
function Get-SelectedObjectTypeString
|
|
{
|
|
if ($script:IntuneManagerSelectedObject -is [IntunePolicyGroupBase]) { return "PolicyGroup" }
|
|
if ($script:IntuneManagerSelectedObject -is [IntunePolicyTypeBase]) { return "PolicyType" }
|
|
return ""
|
|
}
|
|
|
|
function Get-IntuneManagerSelectedPolicyTypes
|
|
{
|
|
[CmdletBinding()]
|
|
param()
|
|
|
|
$policyTypes = @()
|
|
if ($script:IntuneManagerSelectedObject -is [IntunePolicyGroupBase]) {
|
|
$policyTypes += $script:IntuneManagerSelectedObject.PolicyTypes
|
|
} else {
|
|
$policyTypes += $script:IntuneManagerSelectedObject
|
|
}
|
|
return $policyTypes
|
|
}
|
|
|
|
function Get-GraphPolicyForUIList
|
|
{
|
|
[CmdletBinding()]
|
|
[OutputType([IntuneObjectRowItem[]])]
|
|
param(
|
|
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
|
|
[IntunePolicyBase[]]
|
|
$InputObject
|
|
)
|
|
|
|
Begin {
|
|
$rows = [System.Collections.Generic.List[IntuneObjectRowItem]]::new()
|
|
}
|
|
|
|
Process {
|
|
foreach ($policy in $InputObject) {
|
|
if ($null -eq $policy) { continue }
|
|
|
|
$row = [IntuneObjectRowItem]::new()
|
|
$row.IsSelected = $false
|
|
$row.Name = [string]$policy.Name
|
|
$row.PolicyName = [string]$policy.PolicyName
|
|
$row.Platform = [string]$policy.Platform
|
|
$row.Description = if ($policy.Description) { [string]$policy.Description } else { '' }
|
|
$row.LastModified = if ($policy.LastModified) { [string]$policy.LastModified } else { '' }
|
|
$row.Created = if ($policy.Created) { [string]$policy.Created } else { '' }
|
|
$row.ID = [string]$policy.ID
|
|
$row.JsonObject = $policy.Object
|
|
$row.Source = $policy
|
|
$row.TokenId = if ($null -ne $policy.TokenId) { [int]$policy.TokenId } else { 0 }
|
|
try { $row.ScopeTags = [string]$policy.ScopeTags } catch { }
|
|
|
|
[void]$rows.Add($row)
|
|
}
|
|
}
|
|
|
|
End {
|
|
# In-place typed sort. Routing through `Sort-Object` would push each
|
|
# IntuneObjectRowItem through the PSObject pipeline which stamps an ETS
|
|
# wrapper on every item; Avalonia's DataGrid text-column binder reflects
|
|
# on the runtime type and renders blank rows against the wrapper
|
|
# (same failure mode as the Where-Object pipeline in the
|
|
# Update-IntuneAssignmentsFilter fix — see [[avalonia-binding-needs-clr-types]]).
|
|
$rows.Sort([Comparison[IntuneObjectRowItem]]{
|
|
param($a, $b)
|
|
[string]::Compare($a.Name, $b.Name, [StringComparison]::OrdinalIgnoreCase)
|
|
})
|
|
|
|
# Emit each row individually as a bare IntuneObjectRowItem. A
|
|
# `,$rows` emit would treat the List as a single pipeline item (List<T>
|
|
# is not auto-unrolled like Object[]) and the caller's `@()` would
|
|
# collapse to a one-element array holding the whole List, which the
|
|
# DataGrid then renders as one blank row.
|
|
foreach ($r in $rows) { $r }
|
|
}
|
|
}
|
|
|
|
function Show-SelectedGraphPolicies
|
|
{
|
|
param([switch]$AllPages, [switch]$SinglePage, [switch]$Restart)
|
|
|
|
$ui = $script:UIProvider
|
|
if ($Restart) { $script:IntuneManagerCurrentPage = $null }
|
|
if (-not $script:IntuneManagerSelectedObject) { return }
|
|
|
|
$params = @{}
|
|
if ($script:IntuneManagerSelectedObject -is [IntunePolicyGroupBase]) { $params.Add('PolicyGroup', $script:IntuneManagerSelectedObject.Id) }
|
|
elseif ($script:IntuneManagerSelectedObject -is [IntunePolicyTypeBase]) { $params.Add('PolicyType', $script:IntuneManagerSelectedObject.Id) }
|
|
|
|
if ($SinglePage -and (Get-SettingValue 'GetAllPages') -ne $true) {
|
|
$params.Add('SinglePage', $true)
|
|
}
|
|
|
|
Write-Status "Loading $($script:IntuneManagerSelectedObject.Title) objects"
|
|
|
|
$viewObject = Get-SingletonObject "IntuneViewObject"
|
|
$panel = if ($viewObject) { $viewObject.ViewPanel } else { $null }
|
|
if ($panel -and $script:IntuneManagerSelectedObject.ShowForm -ne $false) {
|
|
$ui.SetXamlProperty($panel, 'txtFormTitle', 'Text', $script:IntuneManagerSelectedObject.Title)
|
|
try {
|
|
$img = $script:IntuneManagerSelectedObject.GetImage()
|
|
if ($img) { $ui.SetXamlProperty($panel, 'ccIcon', 'Content', $img) }
|
|
} catch { }
|
|
}
|
|
|
|
$rows = @(Get-GraphPolicies @params | Get-GraphPolicyForUIList)
|
|
|
|
|
|
if ($script:dgIntuneManagerObjects) {
|
|
$script:dgIntuneManagerObjects.AutoGenerateColumns = $false
|
|
$script:dgIntuneManagerObjects.Columns.Clear()
|
|
|
|
if ($rows.Count -gt 0) {
|
|
Add-IntuneManagerDataGridColumns
|
|
}
|
|
|
|
|
|
# Avalonia DataGrid binds straight against an enumerable; the WPF
|
|
# CollectionViewSource path doesn't apply. Filter is a subset rebuild
|
|
# (see Invoke-FilterBoxChanged) — stash the unfiltered list so the
|
|
# filter can restore it.
|
|
$script:IntuneManagerAllRows = [System.Collections.Generic.List[object]]::new()
|
|
foreach ($r in $rows) { [void]$script:IntuneManagerAllRows.Add($r) }
|
|
|
|
|
|
$script:dgIntuneManagerObjects.ItemsSource = $script:IntuneManagerAllRows
|
|
|
|
}
|
|
|
|
Set-GraphPagesButtonStatus
|
|
|
|
$policyTypes = Get-IntuneManagerSelectedPolicyTypes
|
|
$allowDelete = Get-SettingValue 'AllowDelete'
|
|
Set-IntuneManagerUIButtonStatus @('btnDelete') $policyTypes -ForceHide:($allowDelete -eq $false)
|
|
Set-IntuneManagerUIButtonStatus @('btnImport','btnView','btnExport','btnCompare','btnCopy','btnDocument') $policyTypes
|
|
if ($script:spIntuneManagerButtons) {
|
|
$btnImport = (Get-AvaloniaHost)::FindByName($script:spIntuneManagerButtons, 'btnImport')
|
|
if ($btnImport) { $btnImport.IsEnabled = $true }
|
|
}
|
|
|
|
|
|
Invoke-FilterBoxChanged $script:IntuneManagementFilterTextBox `
|
|
$script:dgIntuneManagerObjects -ForceUpdate `
|
|
-ObjectsCountTextBox $script:IntuneManagementObjectsCount
|
|
|
|
|
|
Invoke-AppEvent "IntuneManagerUISelectedMenuItemChanged" $policyTypes
|
|
|
|
|
|
Write-Status ""
|
|
}
|
|
|
|
function Add-IntuneManagerDataGridColumns
|
|
{
|
|
if (-not $script:dgIntuneManagerObjects) { return }
|
|
|
|
# WPF parity for the ObjectListFirstLineOnly setting: the 'firstLine' class
|
|
# drives a MaxLines=1 cell style in Themes/Styles.axaml, so a multi-line
|
|
# value (store app descriptions) no longer makes the row as tall as its text.
|
|
if ((Get-SettingValue "ObjectListFirstLineOnly") -eq $true) {
|
|
if (-not $script:dgIntuneManagerObjects.Classes.Contains('firstLine')) { $script:dgIntuneManagerObjects.Classes.Add('firstLine') }
|
|
} else {
|
|
[void]$script:dgIntuneManagerObjects.Classes.Remove('firstLine')
|
|
}
|
|
|
|
# Checkbox column for IsSelected. WPF parity: a CheckBox in the column
|
|
# header toggles every row's IsSelected. Avalonia's DataGridCheckBoxColumn
|
|
# has no bindable header CheckBox, so build a DataGridTemplateColumn:
|
|
# header = a CheckBox control, cell template = a CheckBox bound TwoWay
|
|
# to IsSelected. Initialize-AvaloniaGridSelectAllHeader walks the columns
|
|
# post-add, finds the header CheckBox, and wires its IsCheckedChanged to
|
|
# push IsSelected onto every row in ItemsSource.
|
|
# Header and cell checkboxes are both LEFT-anchored: the Fluent column
|
|
# header does not stretch its content, so a "centered" header checkbox is
|
|
# actually pinned at the header's content-left edge - centering the cell
|
|
# checkboxes can therefore never line up with it. Left-anchoring both
|
|
# puts them on the same edge at any column width.
|
|
$cellTemplateXaml = @'
|
|
<DataTemplate xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
|
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay}" HorizontalAlignment="Left" VerticalAlignment="Center" Margin="12,0,0,0"/>
|
|
</DataTemplate>
|
|
'@
|
|
$headerCheckBox = New-Object Avalonia.Controls.CheckBox
|
|
$headerCheckBox.IsChecked = $false
|
|
$headerCheckBox.HorizontalAlignment = [Avalonia.Layout.HorizontalAlignment]::Left
|
|
$headerCheckBox.VerticalAlignment = [Avalonia.Layout.VerticalAlignment]::Center
|
|
try { $headerCheckBox.SetValue([Avalonia.Controls.ToolTip]::TipProperty, 'Select/deselect all listed items') } catch { }
|
|
|
|
$checkCol = New-Object Avalonia.Controls.DataGridTemplateColumn
|
|
$checkCol.Header = $headerCheckBox
|
|
$checkCol.CellTemplate = (Get-AvaloniaHost)::LoadXaml($cellTemplateXaml)
|
|
$checkCol.CanUserSort = $false
|
|
$script:dgIntuneManagerObjects.Columns.Add($checkCol) | Out-Null
|
|
|
|
$selectAllHeaderCb = Initialize-AvaloniaGridSelectAllHeader -Grid $script:dgIntuneManagerObjects -BindingProperty 'IsSelected' -InitiallyChecked $false
|
|
# Re-evaluate the action buttons after a select-all toggle: the generic
|
|
# helper only flips row IsSelected, so without this the buttons kept the
|
|
# state they had before every row got (un)checked. Module function by name
|
|
# - closure-safe.
|
|
if ($selectAllHeaderCb) {
|
|
$selectAllHeaderCb.add_IsCheckedChanged((ConvertTo-AvaloniaEventScriptBlock {
|
|
Update-IntuneManagerActionButtons
|
|
}))
|
|
}
|
|
|
|
# Resolve the column list. The "ObjectColumns" per-type setting can name
|
|
# paths beyond the canonical IntuneObjectRowItem fields (e.g.
|
|
# `lastModifiedDateTime`, `Object.priority`); those bind through
|
|
# IntunePolicyPathConverter against the row's Source so PSObject
|
|
# NoteProperties resolve correctly (see [[avalonia-binding-needs-clr-types]]).
|
|
$additionalColumns = @()
|
|
$selectedType = Get-SelectedObjectTypeString
|
|
if ($selectedType) {
|
|
$additionalColsStr = Get-SettingStoreValue "IntuneManager\ObjectColumns\$selectedType" "$($script:IntuneManagerSelectedObject.Id)"
|
|
if ($additionalColsStr) {
|
|
$additionalColumns += $additionalColsStr.Split(',')
|
|
}
|
|
}
|
|
|
|
$columns = @()
|
|
if ($additionalColumns.Count -eq 0 -or $additionalColumns[0] -ne '0') {
|
|
$columns += ?? $script:IntuneManagerSelectedObject.ViewProperties @('Name','PolicyName','ID')
|
|
}
|
|
foreach ($extra in $additionalColumns) {
|
|
if ($extra -eq '0' -or $extra -eq '1') { continue }
|
|
$columns += $extra
|
|
}
|
|
|
|
# Map lower-cased name -> the ACTUAL CLR property name. The lookup has to be
|
|
# case-insensitive (view properties are declared as both "ID" and "Id"
|
|
# across the policy classes) but the binding must use the real casing:
|
|
# Avalonia's binder resolves paths by case-SENSITIVE reflection, so binding
|
|
# "Id" against the property declared as "ID" silently rendered an empty
|
|
# column - which is why Applications, Conditional Access and 11 other types
|
|
# showed a blank Id in Avalonia but not in WPF.
|
|
$rowClrProps = @{}
|
|
foreach ($p in [IntuneObjectRowItem].GetProperties()) {
|
|
$rowClrProps[$p.Name.ToLowerInvariant()] = $p.Name
|
|
}
|
|
# Construct via the host factory rather than [FullName]::new() — see the
|
|
# comment on Host.CreatePathConverter for why the name-based resolution
|
|
# blows up after Avalonia's XAML loader populates dynamic assemblies.
|
|
$pathConverter = (Get-AvaloniaHost)::CreatePathConverter()
|
|
|
|
foreach ($columnInfo in $columns) {
|
|
if (-not $columnInfo) { continue }
|
|
$bindingProp, $colHeader = ($columnInfo -split '=', 2)
|
|
if (-not $colHeader) { $colHeader = $bindingProp.Split('.')[-1] }
|
|
|
|
$col = New-Object Avalonia.Controls.DataGridTextColumn
|
|
$col.Header = $colHeader
|
|
$col.IsReadOnly = $true
|
|
|
|
# Direct bind for the canonical row fields (Name, PolicyName, ID, ...);
|
|
# everything else routes through the converter on Source so PSObject
|
|
# NoteProperties get walked.
|
|
#
|
|
# CRITICAL: both bindings must be OneWay. Avalonia's
|
|
# DataGridTextColumn.Binding defaults to TwoWay, and Avalonia's
|
|
# binding engine fires writeback on sort / scroll / focus changes
|
|
# even for IsReadOnly=$true columns. For the converter binding that
|
|
# writeback would call IntunePolicyPathConverter.ConvertBack (which
|
|
# returns null) and **stamp null into the row's Source property** —
|
|
# silently breaking View / Compare / Copy because their handlers
|
|
# read $selectedRow.Source. Verified by the 22-of-78 null-Source
|
|
# diag the user produced 2026-06-07.
|
|
$clrName = $rowClrProps[("$bindingProp").ToLowerInvariant()]
|
|
if ($clrName) {
|
|
# Bind the property's real name, not the caller's spelling.
|
|
$directBinding = New-Object Avalonia.Data.Binding $clrName
|
|
$directBinding.Mode = [Avalonia.Data.BindingMode]::OneWay
|
|
$col.Binding = $directBinding
|
|
} else {
|
|
$b = New-Object Avalonia.Data.Binding 'Source'
|
|
$b.Mode = [Avalonia.Data.BindingMode]::OneWay
|
|
$b.Converter = $pathConverter
|
|
$b.ConverterParameter = $bindingProp
|
|
$col.Binding = $b
|
|
}
|
|
|
|
$script:dgIntuneManagerObjects.Columns.Add($col) | Out-Null
|
|
}
|
|
}
|
|
|
|
function Set-IntuneManagerUIButtonStatus
|
|
{
|
|
param($Buttons, $PolicyTypes, [switch]$ForceHide)
|
|
|
|
if (-not $script:spIntuneManagerButtons) { return }
|
|
|
|
foreach ($btnName in $Buttons) {
|
|
$visible = $false
|
|
if (-not $ForceHide) {
|
|
foreach ($policyType in $PolicyTypes) {
|
|
if (-not $policyType.ShowButtons -or
|
|
($policyType.ShowButtons | Where-Object { $btnName -like "*$_" })) {
|
|
$visible = $true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
$btn = (Get-AvaloniaHost)::FindByName($script:spIntuneManagerButtons, $btnName)
|
|
if ($btn) { $btn.IsVisible = $visible }
|
|
}
|
|
}
|
|
|
|
function Add-GraphPoliciesFromPaging
|
|
{
|
|
param([ValidateSet('NextPage','AllRemainingPages')][string]$PagingType)
|
|
|
|
if (-not $script:IntuneManagerSelectedObject -or -not $script:dgIntuneManagerObjects) { return }
|
|
|
|
Write-Status "Loading $($script:IntuneManagerSelectedObject.Title) objects"
|
|
|
|
$newRows = @(Get-GraphPolicies -Paging $PagingType | Get-GraphPolicyForUIList)
|
|
if ($newRows.Count -gt 0) {
|
|
# Rebuild into a NEW list instance rather than appending in place: the
|
|
# no-filter path assigns this same collection back to ItemsSource, and
|
|
# Avalonia ignores a reassignment of the identical instance - appended
|
|
# rows never rendered.
|
|
$merged = [System.Collections.Generic.List[object]]::new()
|
|
foreach ($r in @($script:IntuneManagerAllRows)) { if ($r) { [void]$merged.Add($r) } }
|
|
foreach ($r in $newRows) { [void]$merged.Add($r) }
|
|
$script:IntuneManagerAllRows = $merged
|
|
}
|
|
|
|
Set-GraphPagesButtonStatus
|
|
Invoke-FilterBoxChanged $script:IntuneManagementFilterTextBox `
|
|
$script:dgIntuneManagerObjects -ForceUpdate `
|
|
-ObjectsCountTextBox $script:IntuneManagementObjectsCount
|
|
Write-Status ""
|
|
}
|
|
|
|
function Set-GraphPagesButtonStatus
|
|
{
|
|
$ui = $script:UIProvider
|
|
$viewObject = Get-SingletonObject "IntuneViewObject"
|
|
if (-not $viewObject) { return }
|
|
$panel = $viewObject.ViewPanel
|
|
$visible = [bool]$script:GraphPagingCache
|
|
$ui.SetXamlProperty($panel, 'btnLoadAllPages', 'IsVisible', $visible)
|
|
$ui.SetXamlProperty($panel, 'btnLoadNextPage', 'IsVisible', $visible)
|
|
}
|
|
|
|
function Clear-GraphObjects
|
|
{
|
|
$ui = $script:UIProvider
|
|
$viewObject = Get-SingletonObject "IntuneViewObject"
|
|
if (-not $viewObject) { return }
|
|
$panel = $viewObject.ViewPanel
|
|
|
|
$ui.SetXamlProperty($panel, 'txtFormTitle', 'Text', '')
|
|
$ui.SetXamlProperty($panel, 'txtObjectsCount', 'Text', '')
|
|
|
|
if ($script:dgIntuneManagerObjects) {
|
|
$script:dgIntuneManagerObjects.Columns.Clear()
|
|
$script:dgIntuneManagerObjects.ItemsSource = $null
|
|
}
|
|
$script:IntuneManagerAllRows = $null
|
|
$script:GraphPagingCache = $null
|
|
|
|
# Clear the filter box AFTER nulling AllRows so the TextChanged it raises
|
|
# hits Invoke-FilterBoxChanged's early return. WPF clears the filter when
|
|
# the grid's ItemsSource changes; without this, stale filter text silently
|
|
# filtered the next policy type the user clicked.
|
|
if ($script:IntuneManagementFilterTextBox) {
|
|
$script:IntuneManagementFilterTextBox.Text = ''
|
|
}
|
|
}
|
|
|
|
function Invoke-RefreshObjects
|
|
{
|
|
if (-not $script:IntuneManagerSelectedObject) { return }
|
|
|
|
$filterText = $null
|
|
if ($script:IntuneManagementFilterTextBox) {
|
|
$filterText = $script:IntuneManagementFilterTextBox.Text
|
|
}
|
|
|
|
Invoke-IntuneActivateObject $script:IntuneManagerSelectedObject
|
|
|
|
if ($filterText -and $script:IntuneManagementFilterTextBox) {
|
|
$script:IntuneManagementFilterTextBox.Text = $filterText
|
|
Invoke-FilterBoxChanged $script:IntuneManagementFilterTextBox `
|
|
$script:dgIntuneManagerObjects -ForceUpdate `
|
|
-ObjectsCountTextBox $script:IntuneManagementObjectsCount
|
|
}
|
|
|
|
Write-Status ""
|
|
}
|
|
|
|
function Invoke-FilterBoxChanged
|
|
{
|
|
param($TxtBox, $DataSource, [switch]$ForceUpdate, $ObjectsCountTextBox)
|
|
|
|
if (-not $DataSource) { return }
|
|
if ($null -eq $script:IntuneManagerAllRows) {
|
|
if ($ObjectsCountTextBox) { $ObjectsCountTextBox.Text = '' }
|
|
return
|
|
}
|
|
|
|
$textValue = if ($TxtBox) { [string]$TxtBox.Text } else { '' }
|
|
$hasText = -not [string]::IsNullOrEmpty($textValue)
|
|
# Platform set lives at $script: so the gear's Filter Platforms submenu can
|
|
# mutate it and re-call this with -ForceUpdate (mirrors the WPF filter).
|
|
$platformSet = $script:_intunePlatformFilter
|
|
$hasPlatform = ($null -ne $platformSet -and $platformSet.Count -gt 0)
|
|
|
|
if ($hasText -or $hasPlatform) {
|
|
$escaped = if ($hasText) { [regex]::Escape($textValue) } else { $null }
|
|
$filtered = [System.Collections.Generic.List[object]]::new()
|
|
foreach ($row in $script:IntuneManagerAllRows) {
|
|
# Platform filter - empty Platform maps to the "" sentinel so a
|
|
# "(no platform)" selection can target rows whose .Platform is
|
|
# null/empty without ambiguity.
|
|
if ($hasPlatform) {
|
|
$plat = $row.Platform
|
|
$key = if ([string]::IsNullOrEmpty($plat)) { "" } else { [string]$plat }
|
|
if (-not $platformSet.Contains($key)) { continue }
|
|
}
|
|
if ($hasText) {
|
|
$match = $false
|
|
foreach ($prop in 'Name','PolicyName','Platform','LastModified','Created','ID','ScopeTags') {
|
|
$val = $row.$prop
|
|
if ($null -ne $val -and ($val -match $escaped)) { $match = $true; break }
|
|
}
|
|
if (-not $match) { continue }
|
|
}
|
|
[void]$filtered.Add($row)
|
|
}
|
|
$DataSource.ItemsSource = $filtered
|
|
}
|
|
else {
|
|
$DataSource.ItemsSource = $script:IntuneManagerAllRows
|
|
}
|
|
|
|
if ($ObjectsCountTextBox) {
|
|
$loadedCount = $script:IntuneManagerAllRows.Count
|
|
$visibleCount = if ($DataSource.ItemsSource) { @($DataSource.ItemsSource).Count } else { 0 }
|
|
$morePages = [bool]$script:GraphPagingCache
|
|
$loadedLabel = if ($morePages) { "$loadedCount+" } else { "$loadedCount" }
|
|
|
|
if ($loadedCount -le 0) {
|
|
$ObjectsCountTextBox.Text = ''
|
|
}
|
|
elseif ($visibleCount -lt $loadedCount) {
|
|
$ObjectsCountTextBox.Text = "Showing $visibleCount of $loadedLabel"
|
|
}
|
|
else {
|
|
$ObjectsCountTextBox.Text = if ($morePages) {
|
|
"Objects: $loadedLabel (more available - click Load All)"
|
|
} else {
|
|
"Objects: $loadedCount"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function Invoke-IntuneUIAppInitialized
|
|
{
|
|
# Settings registrations moved out of this handler (2026-08-15):
|
|
# GraphPageSize -> Internal/IntuneManager.ps1 (engine-consumed);
|
|
# GetAllPages / ObjectViewType -> UI/Classes/UICommonSettings.ps1 (shared by
|
|
# both backends, registered once).
|
|
|
|
Add-ViewObject (Get-SingletonObject "IntuneViewObject")
|
|
|
|
Add-AppEventHandler "AuthenticatedNewToken" "Invoke-IntuneUIEventNewAuthentication"
|
|
Add-AppEventHandler "AuthenticationUserDisconnected" "Invoke-IntuneUIEventUserDisconnected"
|
|
Add-AppEventHandler "AuthenticationFailed" "Invoke-IntuneUIEventAuthenticationFailed"
|
|
}
|
|
|
|
function Invoke-IntuneUIEventSettingsUpdated
|
|
{
|
|
if ((Get-SettingValue "ObjectViewType") -ne (Get-CacheObject "ObjectViewType-Cached")) {
|
|
Set-CacheObject "ObjectViewType-Cached" (Get-SettingValue "ObjectViewType")
|
|
Show-ViewMenu
|
|
}
|
|
# AllowDelete / AllowBulkDelete were only honoured while building the view
|
|
# and the Bulk menu, so toggling them in Settings did nothing until the
|
|
# next restart (WPF applies them immediately).
|
|
Update-IntuneDeleteVisibility
|
|
}
|
|
|
|
function Invoke-IntuneUIEventNewAuthentication
|
|
{
|
|
if ($script:ActiveView -and $script:ActiveView.Id -eq "IntuneManagement") {
|
|
Update-IntuneDeleteVisibility
|
|
|
|
# Access marking (Internal/AccessLevel.ps1) needs the signed-in token,
|
|
# which only exists once login completes. The nav was built white before
|
|
# the token arrived, so rebuild it now to repaint the AccessType colours -
|
|
# otherwise the menu stays uncoloured until the next view switch. A plain
|
|
# Show-ViewMenu blanked the loaded view because setting ItemsSource fires
|
|
# the selection-changed event with a null selection; wrap it in the same
|
|
# suppress+restore guard the count-label refresh uses so the rebuild
|
|
# repaints without dropping the loaded list, then refresh objects.
|
|
if (Get-Command Show-ViewMenu -ErrorAction SilentlyContinue) {
|
|
$script:_suppressMenuSelectionEvents = $true
|
|
try {
|
|
# Restore by identity, not by reference. Show-ViewMenu binds FRESH
|
|
# rows - Get-IntuneViewItems wraps every type in a new ViewMenuItem
|
|
# on each call - so the old SelectedItem is never in the new list,
|
|
# and assigning it back left SelectedIndex at -1: the nav lost its
|
|
# selection at every login even though the same group was still
|
|
# there. (The count-label refresh can restore by reference because
|
|
# it re-binds the very same row objects.)
|
|
$sel = if ($script:lstMenuItems) { $script:lstMenuItems.SelectedItem } else { $null }
|
|
Show-ViewMenu
|
|
if ($sel -and $script:lstMenuItems) {
|
|
$match = $null
|
|
foreach ($row in @($script:lstMenuItems.ItemsSource)) {
|
|
if ($row -and -not $row.IsHeader -and $row.Id -eq $sel.Id -and $row.Title -eq $sel.Title) { $match = $row; break }
|
|
}
|
|
if ($match) { $script:lstMenuItems.SelectedItem = $match }
|
|
}
|
|
}
|
|
finally {
|
|
$script:_suppressMenuSelectionEvents = $false
|
|
}
|
|
}
|
|
|
|
Invoke-RefreshObjects
|
|
}
|
|
}
|
|
|
|
function Invoke-IntuneUIEventUserDisconnected
|
|
{
|
|
if ($script:ActiveView -and $script:ActiveView.Id -eq "IntuneManagement") {
|
|
Update-IntuneDeleteVisibility
|
|
|
|
# Mirror of the login handler: signed out there is no token, so
|
|
# Update-IntuneAccessLevels (inside Show-ViewMenu) resets every type/group
|
|
# to Full and clears the orange/red marking. AccessType has no change
|
|
# notification, so rebuild the nav to repaint the default colours. Do NOT
|
|
# use the suppress+restore guard here: letting the selection fall to null
|
|
# fires OnItemChanged($null), which clears the object list the signed-out
|
|
# session can no longer load.
|
|
if (Get-Command Show-ViewMenu -ErrorAction SilentlyContinue) {
|
|
Show-ViewMenu
|
|
}
|
|
}
|
|
}
|
|
|
|
function Invoke-IntuneUIEventAuthenticationFailed
|
|
{
|
|
param($TokenInfo)
|
|
|
|
# AuthenticationFailed also fires on transient failures - this tenant's WAM
|
|
# broker fails every silent refresh while the token is still perfectly valid
|
|
# (see the SuppressFailedEvent handling in the auth core) - so rebuilding on
|
|
# every failure would wrongly wipe the marking mid-session. Only treat it as a
|
|
# sign-out when the default token is genuinely past expiry; Test-DefaultTokenExpired
|
|
# returns $false for still-valid and SDK-managed tokens. When it is expired, the
|
|
# reset is identical to an explicit disconnect, so reuse that handler (no
|
|
# selection restore -> the object grid clears too).
|
|
if (-not (Test-DefaultTokenExpired)) { return }
|
|
Invoke-IntuneUIEventUserDisconnected $TokenInfo
|
|
}
|
|
|
|
# Live-apply the delete-permission settings (WPF parity: the same trio lives
|
|
# in UI/WPF/Extensions/IntuneManagerUIWPF.ps1).
|
|
function Update-IntuneDeleteVisibility
|
|
{
|
|
Update-IntuneManagerDeleteButton
|
|
Update-IntuneBulkDeleteMenu
|
|
}
|
|
|
|
function Update-IntuneManagerDeleteButton
|
|
{
|
|
if (-not $script:spIntuneManagerButtons) { return }
|
|
|
|
$allowDelete = (Get-SettingValue 'AllowDelete') -eq $true
|
|
# Route through the normal status path so the selected policy type's
|
|
# ShowButtons rules still apply on top of the permission setting.
|
|
$policyTypes = @(Get-IntuneManagerSelectedPolicyTypes)
|
|
Set-IntuneManagerUIButtonStatus @('btnDelete') $policyTypes -ForceHide:(-not $allowDelete)
|
|
}
|
|
|
|
function Update-IntuneBulkDeleteMenu
|
|
{
|
|
if (-not $script:mnuMain) { return }
|
|
$bulkMenu = $script:mnuMain.Items | Where-Object { $_.Name -eq 'IntuneBulk' } | Select-Object -First 1
|
|
if (-not $bulkMenu) { return }
|
|
$deleteItem = $bulkMenu.Items | Where-Object { $_.Name -eq 'mnuBulkDelete' } | Select-Object -First 1
|
|
if (-not $deleteItem) { return }
|
|
|
|
# Avalonia uses a bool IsVisible, not WPF's Visibility enum.
|
|
$deleteItem.IsVisible = ((Get-SettingValue 'AllowBulkDelete') -eq $true)
|
|
}
|
|
|
|
# Avalonia port of UI/WPF/Extensions/IntuneManagerUIWPF.ps1
|
|
# Update-MenuTitleConfigForIntuneView. Show-ViewMenu (CoreUIAvalonia) calls
|
|
# this whenever the Intune view becomes active or its menu is rebuilt.
|
|
# Reveals the gear button next to the menu title and syncs / wires the
|
|
# Group / API / Show item counts / Filter platforms / Refresh items in its
|
|
# MenuFlyout (full WPF parity).
|
|
function Update-AvaloniaMenuTitleConfigForIntuneView
|
|
{
|
|
if (-not $script:btnMenuTitleConfig) { return }
|
|
if (-not $script:Window) { return }
|
|
|
|
$hostType = Get-AvaloniaHost
|
|
$script:btnMenuTitleConfig.IsVisible = $true
|
|
try { $script:btnMenuTitleConfig.SetValue([Avalonia.Controls.ToolTip]::TipProperty, 'Menu options: switch view, refresh') } catch { }
|
|
|
|
$script:mnuMenuTitleConfigGroup = $hostType::FindByName($script:Window, 'mnuMenuTitleConfigGroup')
|
|
$script:mnuMenuTitleConfigType = $hostType::FindByName($script:Window, 'mnuMenuTitleConfigType')
|
|
$script:mnuMenuTitleConfigShowCounts = $hostType::FindByName($script:Window, 'mnuMenuTitleConfigShowCounts')
|
|
$script:mnuMenuTitleConfigFilterPlatforms = $hostType::FindByName($script:Window, 'mnuMenuTitleConfigFilterPlatforms')
|
|
$script:mnuMenuTitleConfigRefresh = $hostType::FindByName($script:Window, 'mnuMenuTitleConfigRefresh')
|
|
$script:mnuMenuTitleConfigShowInfo = $hostType::FindByName($script:Window, 'mnuMenuTitleConfigShowInfo')
|
|
|
|
$current = Get-SettingValue 'ObjectViewType'
|
|
# "Group" is the default; anything not explicitly "Type" reads as Group
|
|
# so a missing/blank setting still ticks Group rather than leaving both
|
|
# unchecked.
|
|
if ($script:mnuMenuTitleConfigGroup) { $script:mnuMenuTitleConfigGroup.IsChecked = ($current -ne 'Type') }
|
|
if ($script:mnuMenuTitleConfigType) { $script:mnuMenuTitleConfigType.IsChecked = ($current -eq 'Type') }
|
|
|
|
# Read-only "Intune Info" group is opt-in (hidden by default). Sync the toggle.
|
|
if ($script:mnuMenuTitleConfigShowInfo) { $script:mnuMenuTitleConfigShowInfo.IsChecked = ((Get-SettingStoreValue "IntuneManager" "ShowIntuneInfo" "false") -eq "true") }
|
|
|
|
# "Show item counts" is HIDDEN per user decision (2026-06-12) — the menu
|
|
# item is IsVisible=False in MainWindow.axaml and the populate paths below
|
|
# are forced off so a previously-saved true setting can't resurrect it.
|
|
# The counts machinery is kept for potential re-enable.
|
|
$showCounts = $false
|
|
if ($script:mnuMenuTitleConfigShowCounts) { $script:mnuMenuTitleConfigShowCounts.IsChecked = $showCounts }
|
|
|
|
if (-not $script:_menuTitleConfigWired) {
|
|
$script:_menuTitleConfigWired = $true
|
|
|
|
if ($script:mnuMenuTitleConfigGroup) {
|
|
$script:mnuMenuTitleConfigGroup.add_Click((ConvertTo-AvaloniaEventScriptBlock {
|
|
Set-AvaloniaIntuneMenuObjectViewType 'Group'
|
|
}))
|
|
}
|
|
if ($script:mnuMenuTitleConfigType) {
|
|
$script:mnuMenuTitleConfigType.add_Click((ConvertTo-AvaloniaEventScriptBlock {
|
|
Set-AvaloniaIntuneMenuObjectViewType 'Type'
|
|
}))
|
|
}
|
|
if ($script:mnuMenuTitleConfigShowInfo) {
|
|
$script:mnuMenuTitleConfigShowInfo.add_Click((ConvertTo-AvaloniaEventScriptBlock {
|
|
# ToggleType=CheckBox flips IsChecked before the handler runs. Persist +
|
|
# rebuild the nav so the read-only Info group appears/disappears.
|
|
$enabled = [bool]$script:mnuMenuTitleConfigShowInfo.IsChecked
|
|
Save-SettingStoreValue -SubPath "IntuneManager" -Key "ShowIntuneInfo" -Value ($enabled.ToString().ToLower())
|
|
Show-ViewMenu
|
|
}))
|
|
}
|
|
if ($script:mnuMenuTitleConfigRefresh) {
|
|
$script:mnuMenuTitleConfigRefresh.add_Click((ConvertTo-AvaloniaEventScriptBlock {
|
|
# Re-bind the menu list (cheap - no Graph). Count re-fetch
|
|
# removed with the hidden "Show item counts" feature.
|
|
Show-ViewMenu
|
|
}))
|
|
}
|
|
if ($script:mnuMenuTitleConfigShowCounts) {
|
|
$script:mnuMenuTitleConfigShowCounts.add_Click((ConvertTo-AvaloniaEventScriptBlock {
|
|
# ToggleType=CheckBox items flip IsChecked before the handler runs.
|
|
$enabled = [bool]$script:mnuMenuTitleConfigShowCounts.IsChecked
|
|
Save-SettingStoreValue -SubPath "IntuneManager" -Key "ShowMenuItemCounts" -Value ($enabled.ToString().ToLower())
|
|
if ($enabled) {
|
|
Update-AvaloniaIntuneViewItemCounts
|
|
}
|
|
else {
|
|
# Strip counts back to bare Title without a fresh batch.
|
|
Update-AvaloniaIntuneMenuLabels -Reset
|
|
}
|
|
}))
|
|
}
|
|
if ($script:mnuMenuTitleConfigFilterPlatforms -and $script:btnMenuTitleConfig.Flyout) {
|
|
# Populate on the parent flyout's Opened (the submenu items must
|
|
# exist BEFORE the user hovers into the submenu). Cheap: in-memory
|
|
# scan of the already-bound right-pane rows, no Graph.
|
|
$script:btnMenuTitleConfig.Flyout.add_Opened((ConvertTo-AvaloniaEventScriptBlock {
|
|
Update-AvaloniaIntuneFilterPlatformsSubmenu
|
|
}))
|
|
}
|
|
}
|
|
|
|
# First paint after rebind: if counts are on, populate them. Uses the
|
|
# session count cache so a view-switch back doesn't trigger a fresh
|
|
# batch - only first activation or explicit Refresh does.
|
|
if ($showCounts) {
|
|
Update-AvaloniaIntuneViewItemCounts
|
|
}
|
|
}
|
|
|
|
# Project the shared per-type counts (Get-IntuneTypeCounts) onto the bound
|
|
# [ViewMenuItem] rows. Type rows: direct lookup. Group rows: sum of every
|
|
# child type's count. ViewMenuItem has no INPC so the labels only repaint
|
|
# after an ItemsSource rebind; selection is preserved with the suppression
|
|
# flag so the rebind can't retrigger a Graph reload of the current type.
|
|
function Update-AvaloniaIntuneViewItemCounts
|
|
{
|
|
param([switch]$Force)
|
|
|
|
if (-not $script:lstMenuItems -or -not $script:lstMenuItems.ItemsSource) { return }
|
|
|
|
$tokenId = $null
|
|
try { $tokenId = Get-DefaultTokenId } catch { }
|
|
if ($null -eq $tokenId) {
|
|
Write-LogDebug "Update-AvaloniaIntuneViewItemCounts: no token id available - skipping"
|
|
return
|
|
}
|
|
|
|
$counts = Get-IntuneTypeCounts -Force:$Force -TokenId $tokenId
|
|
Update-AvaloniaIntuneMenuLabels -Counts $counts
|
|
}
|
|
|
|
# Rewrites MenuLabel on every bound row ("Title (N)" or bare Title with
|
|
# -Reset) and rebinds the ListBox so the non-INPC rows repaint.
|
|
function Update-AvaloniaIntuneMenuLabels
|
|
{
|
|
param([hashtable]$Counts, [switch]$Reset)
|
|
|
|
if (-not $script:lstMenuItems -or -not $script:lstMenuItems.ItemsSource) { return }
|
|
$rows = @($script:lstMenuItems.ItemsSource)
|
|
|
|
foreach ($row in $rows) {
|
|
if (-not $row -or $row.IsHeader) { continue }
|
|
if ($Reset -or -not $Counts) {
|
|
$row.MenuLabel = [string]$row.Title
|
|
continue
|
|
}
|
|
$payload = $row.Tag
|
|
$count = $null
|
|
if ($payload -is [IntunePolicyTypeBase]) {
|
|
if ($Counts.ContainsKey($payload.Id)) { $count = $Counts[$payload.Id] }
|
|
}
|
|
elseif ($payload -is [IntunePolicyGroupBase]) {
|
|
$sum = 0; $hasAny = $false
|
|
foreach ($child in @($payload.PolicyTypes)) {
|
|
if ($child -and $Counts.ContainsKey($child.Id)) {
|
|
$sum += $Counts[$child.Id]; $hasAny = $true
|
|
}
|
|
}
|
|
if ($hasAny) { $count = $sum }
|
|
}
|
|
$row.MenuLabel = if ($null -ne $count) { "$($row.Title) ($count)" } else { [string]$row.Title }
|
|
}
|
|
|
|
$script:_suppressMenuSelectionEvents = $true
|
|
try {
|
|
$sel = $script:lstMenuItems.SelectedItem
|
|
$script:lstMenuItems.ItemsSource = $null
|
|
$script:lstMenuItems.ItemsSource = $rows
|
|
if ($sel) { $script:lstMenuItems.SelectedItem = $sel }
|
|
}
|
|
finally {
|
|
$script:_suppressMenuSelectionEvents = $false
|
|
}
|
|
}
|
|
|
|
# Rebuilds the Filter Platforms submenu from the currently-bound right-pane
|
|
# rows. Mirrors the WPF implementation: one checkable item per platform,
|
|
# "(no platform)" pinned at the bottom, Clear filter footer when active.
|
|
function Update-AvaloniaIntuneFilterPlatformsSubmenu
|
|
{
|
|
if (-not $script:mnuMenuTitleConfigFilterPlatforms) { return }
|
|
|
|
# 1. Collect platforms in the current grid (the unfiltered backing list).
|
|
$available = [System.Collections.Generic.HashSet[string]]::new()
|
|
$hasNull = $false
|
|
if ($script:IntuneManagerAllRows) {
|
|
foreach ($it in $script:IntuneManagerAllRows) {
|
|
$plat = $it.Platform
|
|
if ([string]::IsNullOrEmpty($plat)) { $hasNull = $true }
|
|
else { [void]$available.Add([string]$plat) }
|
|
}
|
|
}
|
|
|
|
# 2. Include currently-selected filters so the user can untick them even
|
|
# if the new view no longer has any matching rows.
|
|
if ($script:_intunePlatformFilter) {
|
|
foreach ($s in $script:_intunePlatformFilter) {
|
|
if ($s -eq "") { $hasNull = $true }
|
|
else { [void]$available.Add($s) }
|
|
}
|
|
}
|
|
|
|
$script:mnuMenuTitleConfigFilterPlatforms.Items.Clear()
|
|
|
|
$sorted = @($available | Sort-Object)
|
|
if ($sorted.Count -eq 0 -and -not $hasNull) {
|
|
$empty = New-Object Avalonia.Controls.MenuItem
|
|
$empty.Header = "(no policies loaded)"
|
|
$empty.IsEnabled = $false
|
|
[void]$script:mnuMenuTitleConfigFilterPlatforms.Items.Add($empty)
|
|
return
|
|
}
|
|
|
|
# 3. One MenuItem per platform; "(no platform)" pinned at the bottom.
|
|
$platformClick = (ConvertTo-AvaloniaEventScriptBlock {
|
|
param($s, $e)
|
|
Switch-AvaloniaIntunePlatformFilter ([string]$s.Tag) ([bool]$s.IsChecked)
|
|
})
|
|
foreach ($plat in ($sorted + $(if ($hasNull) { @($null) } else { @() }))) {
|
|
$mi = New-Object Avalonia.Controls.MenuItem
|
|
$mi.Header = if ($null -eq $plat) { "(no platform)" } else { $plat }
|
|
$mi.ToggleType = [Avalonia.Controls.MenuItemToggleType]::CheckBox
|
|
# Tag stores the canonical key ("" sentinel for the no-platform row).
|
|
$mi.Tag = if ($null -eq $plat) { "" } else { $plat }
|
|
$mi.IsChecked = ($script:_intunePlatformFilter -and $script:_intunePlatformFilter.Contains([string]$mi.Tag))
|
|
$mi.add_Click($platformClick)
|
|
[void]$script:mnuMenuTitleConfigFilterPlatforms.Items.Add($mi)
|
|
}
|
|
|
|
# 4. "Clear filter" footer when any filter is active.
|
|
if ($script:_intunePlatformFilter -and $script:_intunePlatformFilter.Count -gt 0) {
|
|
[void]$script:mnuMenuTitleConfigFilterPlatforms.Items.Add((New-Object Avalonia.Controls.Separator))
|
|
$clear = New-Object Avalonia.Controls.MenuItem
|
|
$clear.Header = "Clear filter"
|
|
$clear.add_Click((ConvertTo-AvaloniaEventScriptBlock {
|
|
if ($script:_intunePlatformFilter) { $script:_intunePlatformFilter.Clear() }
|
|
Invoke-FilterBoxChanged $script:IntuneManagementFilterTextBox `
|
|
$script:dgIntuneManagerObjects -ForceUpdate `
|
|
-ObjectsCountTextBox $script:IntuneManagementObjectsCount
|
|
}))
|
|
[void]$script:mnuMenuTitleConfigFilterPlatforms.Items.Add($clear)
|
|
}
|
|
}
|
|
|
|
# Toggle a single platform on/off in $script:_intunePlatformFilter and
|
|
# re-evaluate the grid filter.
|
|
function Switch-AvaloniaIntunePlatformFilter
|
|
{
|
|
param([string]$PlatformKey, [bool]$Enable)
|
|
|
|
if ($null -eq $script:_intunePlatformFilter) {
|
|
$script:_intunePlatformFilter = [System.Collections.Generic.HashSet[string]]::new()
|
|
}
|
|
if ($Enable) { [void]$script:_intunePlatformFilter.Add($PlatformKey) }
|
|
else { [void]$script:_intunePlatformFilter.Remove($PlatformKey) }
|
|
|
|
Invoke-FilterBoxChanged $script:IntuneManagementFilterTextBox `
|
|
$script:dgIntuneManagerObjects -ForceUpdate `
|
|
-ObjectsCountTextBox $script:IntuneManagementObjectsCount
|
|
}
|
|
|
|
function Set-AvaloniaIntuneMenuObjectViewType
|
|
{
|
|
param([ValidateSet('Group','Type')][string]$ViewType)
|
|
|
|
$current = Get-SettingValue 'ObjectViewType'
|
|
if ($current -eq $ViewType) {
|
|
# ToggleType=CheckBox items flip IsChecked before the handler fires.
|
|
# Re-sync so the user can't toggle one off without picking the other.
|
|
if ($script:mnuMenuTitleConfigGroup) { $script:mnuMenuTitleConfigGroup.IsChecked = ($ViewType -eq 'Group') }
|
|
if ($script:mnuMenuTitleConfigType) { $script:mnuMenuTitleConfigType.IsChecked = ($ViewType -eq 'Type') }
|
|
return
|
|
}
|
|
|
|
Save-SettingStoreValue -SubPath 'IntuneManager' -Key 'ObjectViewType' -Value $ViewType
|
|
Set-CacheObject 'ObjectViewType-Cached' $ViewType
|
|
if ($script:mnuMenuTitleConfigGroup) { $script:mnuMenuTitleConfigGroup.IsChecked = ($ViewType -eq 'Group') }
|
|
if ($script:mnuMenuTitleConfigType) { $script:mnuMenuTitleConfigType.IsChecked = ($ViewType -eq 'Type') }
|
|
|
|
Show-ViewMenu
|
|
}
|