IntuneManagement 4.0.0-beta1

This commit is contained in:
Mikael Karlsson
2026-09-23 19:13:09 +10:00
commit 7869619510
892 changed files with 577109 additions and 0 deletions
@@ -0,0 +1,417 @@
# Object picker sub-modal (Avalonia).
#
# Split out of CoreUIAvalonia.ps1 so the shell file stays inside its R9 line
# budget. Avalonia port of the WPF Show-PickerDialog / Show-ObjectPickerDialog
# pair: a Window-shaped dialog shown through Host::ShowDialog for synchronous
# semantics, so callers can write
# $selected = Show-ObjectPickerDialog ...; if ($selected) { ... }
# A grdModal overlay would have to push a DispatcherFrame from PowerShell to
# block, which the C# side already wraps.
# Show a one-line message in the picker's status area (hidden when empty).
function Set-PickerStatusText
{
param([string]$Text)
if (-not $script:txtPickerStatus -or -not $script:grpPickerStatus) { return }
$script:txtPickerStatus.Text = $Text
$script:grpPickerStatus.IsVisible = (-not [String]::IsNullOrWhiteSpace($Text))
}
# The scope object behind the current "Search in" selection, or $null. The
# ComboBox holds [SettingsListItem] projections (Avalonia cannot bind
# NoteProperties), so the real scope comes back through $script:pickerScopeMap.
function Get-PickerSelectedScope
{
if (-not $script:cbPickerScope -or -not $script:pickerScopeMap) { return $null }
$selected = $script:cbPickerScope.SelectedItem
if (-not $selected) { return $null }
return $script:pickerScopeMap[[string]$selected.Value]
}
# The tenant entry behind the current "Tenant" selection, or $null when the
# dialog is single-tenant. Same projection-and-map trick as the scope combo.
function Get-PickerSelectedTenant
{
if (-not $script:cbPickerTenant -or -not $script:pickerTenantMap) { return $null }
$selected = $script:cbPickerTenant.SelectedItem
if (-not $selected) { return $null }
return $script:pickerTenantMap[[string]$selected.Value]
}
# Drop whatever is in the results grid. Called when the tenant changes - those
# rows belong to the previous tenant and picking one would compare the wrong
# object.
function Clear-PickerResults
{
$empty = [System.Collections.Generic.List[PickerObjectRow]]::new()
if ($script:_pickerState) { $script:_pickerState.Rows = $empty }
if ($script:dgPickerObjects) { $script:dgPickerObjects.ItemsSource = $empty }
if ($script:btnPickerOK) { $script:btnPickerOK.IsEnabled = $false }
}
# Wired to the tenant combo's SelectionChanged. A module function rather than an
# inline handler so it can be reached by name from the rebound scriptblock.
function Invoke-PickerTenantChanged
{
Clear-PickerResults
$tenant = Get-PickerSelectedTenant
if ($tenant) { Set-PickerStatusText "Search $($tenant.Title) for an object." }
}
function Show-PickerDialog
{
param(
$Title,
$InitHandler,
$ClickHandler,
$OkHandler,
[switch] $MultiSelect,
$ClickHandlerArgs = $null,
$LoadHandler = $null,
[string] $LoadLabel = "Load all from Intune",
# Entries from Get-PolicySearchScopes. When supplied, the dialog shows
# a "Search in" dropdown above the search box.
$Scopes = $null,
[string] $SelectedScopeKey = $null,
# Entries from Get-PolicySearchTenants. When supplied, the dialog shows
# a "Tenant" dropdown above the scope dropdown.
$Tenants = $null,
[string] $SelectedTenantKey = $null
)
$ui = $script:UIProvider
$dialogXaml = Join-Path $script:AppUIRootFolder 'XAML/PickerDialog.axaml'
$dialog = $ui.GetXamlObject($dialogXaml)
if (-not $dialog) { return }
$dialog.Title = $Title
$hostType = (Get-AvaloniaHost)
$script:pickerDialog = $dialog
$script:dgPickerObjects = $hostType::FindByName($dialog, 'dgPickerObjects')
$script:btnPickerOK = $hostType::FindByName($dialog, 'btnPickerOK')
$script:grpPickerStatus = $hostType::FindByName($dialog, 'grpPickerStatus')
$script:txtPickerStatus = $hostType::FindByName($dialog, 'txtPickerStatus')
$script:txtPickerSearch = $hostType::FindByName($dialog, 'txtPickerSearch')
$btnPickerSearch = $hostType::FindByName($dialog, 'btnPickerSearch')
if ($MultiSelect) {
$script:dgPickerObjects.SelectionMode = [Avalonia.Controls.DataGridSelectionMode]::Extended
}
# Tenant selector - only shown when more than one tenant is signed in.
$script:cbPickerTenant = $hostType::FindByName($dialog, 'cbPickerTenant')
$script:pickerTenantMap = $null
if ($Tenants -and $script:cbPickerTenant) {
$script:pickerTenantMap = @{}
$tenantItems = [System.Collections.Generic.List[SettingsListItem]]::new()
foreach ($tenant in @($Tenants)) {
$script:pickerTenantMap[[string]$tenant.Key] = $tenant
[void]$tenantItems.Add([SettingsListItem]@{ Name = $tenant.Title; Value = $tenant.Key })
}
$script:cbPickerTenant.ItemsSource = $tenantItems
$tenantIndex = 0
if ($SelectedTenantKey) {
for ($i = 0; $i -lt $tenantItems.Count; $i++) {
if ($tenantItems[$i].Value -eq $SelectedTenantKey) { $tenantIndex = $i; break }
}
}
if ($tenantItems.Count -gt 0) { $script:cbPickerTenant.SelectedIndex = $tenantIndex }
$grdPickerTenant = $hostType::FindByName($dialog, 'grdPickerTenant')
if ($grdPickerTenant) { $grdPickerTenant.IsVisible = $true }
# Switching tenant clears the grid: the rows in it came from the
# previous tenant and the search has to be re-run against the new one.
$script:cbPickerTenant.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
Invoke-PickerTenantChanged
}))
}
$script:cbPickerScope = $hostType::FindByName($dialog, 'cbPickerScope')
$script:pickerScopeMap = $null
if ($Scopes -and $script:cbPickerScope) {
$script:pickerScopeMap = @{}
$scopeItems = [System.Collections.Generic.List[SettingsListItem]]::new()
foreach ($scope in @($Scopes)) {
$script:pickerScopeMap[[string]$scope.Key] = $scope
[void]$scopeItems.Add([SettingsListItem]@{ Name = $scope.Title; Value = $scope.Key })
}
$script:cbPickerScope.ItemsSource = $scopeItems
$selectedIndex = 0
if ($SelectedScopeKey) {
for ($i = 0; $i -lt $scopeItems.Count; $i++) {
if ($scopeItems[$i].Value -eq $SelectedScopeKey) { $selectedIndex = $i; break }
}
}
if ($scopeItems.Count -gt 0) { $script:cbPickerScope.SelectedIndex = $selectedIndex }
$grdPickerScope = $hostType::FindByName($dialog, 'grdPickerScope')
if ($grdPickerScope) { $grdPickerScope.IsVisible = $true }
}
$ui.AddXamlEvent($dialog, 'btnPickerCancel', 'add_Click', ((ConvertTo-AvaloniaEventScriptBlock {
$script:pickerDialog.Close()
}.GetNewClosure())))
$script:pickerOKHandler = $OkHandler
$ui.AddXamlEvent($dialog, 'btnPickerOK', 'add_Click', $OkHandler)
$script:pickerSearchHandler = $ClickHandler
if ($ClickHandler) {
$ui.AddXamlEvent($dialog, 'btnPickerSearch', 'add_Click', $ClickHandler)
}
if ($ClickHandlerArgs -and $btnPickerSearch) {
$btnPickerSearch.Tag = $ClickHandlerArgs
}
$btnPickerLoadAll = $hostType::FindByName($dialog, 'btnPickerLoadAll')
if ($btnPickerLoadAll -and $LoadHandler -is [scriptblock]) {
$btnPickerLoadAll.Content = $LoadLabel
$btnPickerLoadAll.IsVisible = $true
$script:pickerLoadHandler = $LoadHandler
$btnPickerLoadAll.add_Click((ConvertTo-AvaloniaEventScriptBlock {
param($S, $E)
try {
# Disabled only for the duration - the scope can change, so a
# second load is legitimate.
$S.IsEnabled = $false
& $script:pickerLoadHandler
} catch {
Write-LogError "Picker LoadHandler failed" $_.Exception
} finally {
$S.IsEnabled = $true
}
}.GetNewClosure()))
}
$script:dgPickerObjects.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
$script:btnPickerOK.IsEnabled = ($null -ne $script:dgPickerObjects.SelectedItem)
}))
$script:dgPickerObjects.add_DoubleTapped((ConvertTo-AvaloniaEventScriptBlock {
if ($script:btnPickerOK.IsEnabled -and $script:pickerOKHandler) {
& $script:pickerOKHandler
}
}))
# Enter in the search box runs the search (matches WPF). Handled is set so
# it does not fall through to btnPickerOK, which is IsDefault.
$script:txtPickerSearch.add_KeyDown((ConvertTo-AvaloniaEventScriptBlock {
param($S, $E)
if ($E.Key -eq [Avalonia.Input.Key]::Enter -and $script:pickerSearchHandler) {
$E.Handled = $true
& $script:pickerSearchHandler
}
}))
if ($InitHandler -is [scriptblock]) {
& $InitHandler
}
$hostType::ShowDialog($script:pickerDialog, $script:Window)
}
# Project a policy object into the CLR row shape the picker DataGrid binds
# against (Avalonia ignores PSObject NoteProperties - see
# [[avalonia-binding-needs-clr-types]]). Module functions rather than
# scriptblocks because the handlers below cannot capture one - rebinding a
# scriptblock to the module strips its closure.
function ConvertTo-PickerObjectRow
{
param($Source)
if (-not $Source) { return $null }
return [PickerObjectRow]@{
Name = [string]$Source.Name
PolicyTypeTitle = if ($Source.PolicyType) { [string]$Source.PolicyType.Title } else { $null }
Description = [string]$Source.Description
Source = $Source
}
}
function ConvertTo-PickerObjectRowList
{
param($Items)
$rows = [System.Collections.Generic.List[PickerObjectRow]]::new()
foreach ($item in @($Items)) {
$row = ConvertTo-PickerObjectRow $item
if ($row) { [void]$rows.Add($row) }
}
return ,$rows
}
function Show-ObjectPickerDialog
{
param(
[string] $Title,
[object[]] $Items,
[object[]] $DisplayColumns,
[scriptblock] $LoadHandler,
[string] $LoadLabel = "Load all from Intune",
# Entries from Get-PolicySearchScopes. When supplied, the dialog shows
# a "Search in" dropdown above the search box.
$Scopes = $null,
[string] $SelectedScopeKey = $null,
# Entries from Get-PolicySearchTenants. When supplied, the dialog shows
# a "Tenant" dropdown above the scope dropdown.
$Tenants = $null,
[string] $SelectedTenantKey = $null,
# Called as & $SearchHandler $searchText $scope and expected to return
# the objects to display. When absent the Search button filters the
# supplied -Items client-side (the original behaviour).
[scriptblock] $SearchHandler = $null
)
# Everything the handlers need lives on $script:_pickerState, NOT in
# captured locals: ConvertTo-AvaloniaEventScriptBlock rebinds each block to
# the module, which strips the closure (proven 2026-08-27 with a real
# function local raised through a real Avalonia Click). Module-scope state,
# module functions by name, $S/$E and sender.Tag are what survive.
#
# DisplayColumns Binding paths are mapped to PickerObjectRow property names;
# only the paths used by current callers (Name, PolicyType.Title,
# Description) are recognised - extend PickerObjectRow and BindingMap
# together if a new column is needed.
$script:_pickerState = @{
Columns = $DisplayColumns
BindingMap = @{
'Name' = 'Name'
'PolicyType.Title' = 'PolicyTypeTitle'
'Description' = 'Description'
}
Rows = (ConvertTo-PickerObjectRowList $Items)
Result = @{ Value = $null }
SearchHandler = $SearchHandler
LoadHandler = $LoadHandler
}
$initHandler = (ConvertTo-AvaloniaEventScriptBlock {
$ps = $script:_pickerState
foreach ($colDef in $ps.Columns) {
$col = [Avalonia.Controls.DataGridTextColumn]::new()
$col.Header = $colDef.Header
$col.IsReadOnly = $true
$bindingPath = $ps.BindingMap[[string]$colDef.Binding]
if (-not $bindingPath) {
Write-LogError "Show-ObjectPickerDialog: unsupported column binding '$($colDef.Binding)' - extend PickerObjectRow + BindingMap together"
continue
}
$col.Binding = [Avalonia.Data.Binding]::new($bindingPath)
$col.Width = [Avalonia.Controls.DataGridLength]::new(1, [Avalonia.Controls.DataGridLengthUnitType]::Star)
$script:dgPickerObjects.Columns.Add($col) | Out-Null
}
$script:dgPickerObjects.ItemsSource = $ps.Rows
})
$clickHandler = (ConvertTo-AvaloniaEventScriptBlock {
$ps = $script:_pickerState
$filter = [string]$script:txtPickerSearch.Text
if ($ps.SearchHandler) {
# Server-side search: ask the caller for the objects matching the
# text in the selected scope and show exactly what comes back. The
# status line belongs to the search handler - it knows whether an
# empty result means "no matches" or "keep typing".
$scope = Get-PickerSelectedScope
try {
$found = @(& $ps.SearchHandler $filter $scope)
}
catch {
Write-LogError "Picker SearchHandler failed" $_.Exception
$found = @()
}
$ps.Rows = (ConvertTo-PickerObjectRowList $found)
$script:dgPickerObjects.ItemsSource = $ps.Rows
return
}
if ([string]::IsNullOrEmpty($filter)) {
$script:dgPickerObjects.ItemsSource = $ps.Rows
} else {
# Where-Object stamps PSObject ETS wrappers; Avalonia DataGrid's
# text-column binder reflects on the runtime type and renders blank
# cells against the wrapper (see [[avalonia-binding-needs-clr-types]]).
$filtered = [System.Collections.Generic.List[PickerObjectRow]]::new()
foreach ($r in $ps.Rows) {
if ($r.Name -ilike "*$filter*") { [void]$filtered.Add($r) }
}
$script:dgPickerObjects.ItemsSource = $filtered
}
})
$okHandler = (ConvertTo-AvaloniaEventScriptBlock {
$row = $script:dgPickerObjects.SelectedItem
if ($row) {
$script:_pickerState.Result.Value = $row.Source
}
$script:pickerDialog.Close()
})
$wrappedLoadHandler = $null
if ($LoadHandler) {
$wrappedLoadHandler = (ConvertTo-AvaloniaEventScriptBlock {
$ps = $script:_pickerState
$newItems = & $ps.LoadHandler
if ($ps.SearchHandler) {
# Scoped loads replace rather than merge - the returned set IS
# everything in the newly selected scope, and merging would
# leave stale rows from a previous scope in the grid.
$ps.Rows = (ConvertTo-PickerObjectRowList $newItems)
$script:dgPickerObjects.ItemsSource = $ps.Rows
return
}
if ($newItems) {
# Dedupe by Id; new entries appended so cached items stay first.
$existingIds = @{}
foreach ($r in $ps.Rows) {
if ($r.Source -and $r.Source.Id) { $existingIds[$r.Source.Id] = $true }
}
$merged = [System.Collections.Generic.List[PickerObjectRow]]::new()
foreach ($r in $ps.Rows) { [void]$merged.Add($r) }
foreach ($n in $newItems) {
if (-not $n) { continue }
if ($n.Id -and $existingIds.ContainsKey($n.Id)) { continue }
$row = ConvertTo-PickerObjectRow $n
if ($row) { [void]$merged.Add($row) }
}
$ps.Rows = $merged
}
Invoke-PickerSearchHandler
})
}
try {
Show-PickerDialog -Title $Title -InitHandler $initHandler -ClickHandler $clickHandler -OkHandler $okHandler -LoadHandler $wrappedLoadHandler -LoadLabel $LoadLabel -Scopes $Scopes -SelectedScopeKey $SelectedScopeKey -Tenants $Tenants -SelectedTenantKey $SelectedTenantKey
return $script:_pickerState.Result.Value
}
finally {
# Do not keep the handlers / rows alive between dialog invocations.
$script:_pickerState = $null
}
}
# Re-run the picker's current search/filter. A module function so the load
# handler can trigger it without capturing the click handler.
function Invoke-PickerSearchHandler
{
if ($script:pickerSearchHandler) { & $script:pickerSearchHandler }
}