Files
IntuneManagement/UI/WPF/Extensions/IntuneManagerBulkAssignmentsUIWPF.ps1
T

784 lines
37 KiB
PowerShell

# Bulk Assignments form + assignment-row helpers.
# Split out of IntuneManagerUIWPF.ps1 on 2026-06-03 to match the Avalonia tree's
# per-feature layout (architecture rule R9 — keep the WPF shell from growing further).
function Show-GraphBulkAssignmentsForm
{
$script:bulkAssignForm = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\BulkAssignmentsForm.xaml"))
if(-not $script:bulkAssignForm) { return }
$script:dgBulkAssignObjects = $script:bulkAssignForm.FindName("dgBulkAssignObjects")
$script:dgBulkAssignList = $script:bulkAssignForm.FindName("dgBulkAssignList")
$script:txtBulkAssignStatus = $script:bulkAssignForm.FindName("txtBulkAssignStatus")
$assignmentSettings = [IntuneManagerAssignmentSettings]::new()
$script:bulkAssignForm.DataContext = $assignmentSettings
# ── Eligible types: use the same support gate as Set-GraphBulkAssignments
# so the UI does not offer groups/APIs that the command will skip.
$script:bulkAssignEligibleTypes = @($script:IntuneTypes | Where-Object {
Test-BulkAssignmentSupported $_
})
$eligibleTypeIds = @($script:bulkAssignEligibleTypes | ForEach-Object { $_.Id })
$script:bulkAssignEligibleGroups = @($script:IntuneGroups | Where-Object {
$_.Title -and ($_.PolicyTypes | Where-Object { $_.Id -in $eligibleTypeIds })
})
# ── Object list (mirrors Bulk Scope Tag DataGrid setup) ──────────────────
$column = Get-GridCheckboxColumn "Selected"
$script:dgBulkAssignObjects.Columns.Add($column)
$script:bulkAssignSelectAllHeader = $column.Header
$script:bulkAssignSelectAllHeader.IsChecked = $true
$script:bulkAssignSelectAllHeader.add_Click({
foreach($item in $script:dgBulkAssignObjects.ItemsSource) {
$item.Selected = $this.IsChecked
}
$script:dgBulkAssignObjects.Items.Refresh()
})
$col = [System.Windows.Controls.DataGridTextColumn]::new()
$col.Header = "Object type"
$col.IsReadOnly = $true
$col.Binding = [System.Windows.Data.Binding]::new("Title")
$script:dgBulkAssignObjects.Columns.Add($col)
$script:bulkAssignMode = "Group"
Update-BulkAssignObjectList
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "rbBulkAssignViewGroup", "add_Checked", {
$script:bulkAssignMode = "Group"; Update-BulkAssignObjectList
if($script:bulkAssignSelectAllHeader) { $script:bulkAssignSelectAllHeader.IsChecked = $true }
})
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "rbBulkAssignViewType", "add_Checked", {
$script:bulkAssignMode = "Type"; Update-BulkAssignObjectList
if($script:bulkAssignSelectAllHeader) { $script:bulkAssignSelectAllHeader.IsChecked = $true }
})
# ── Action radio → settings.Action sync ───────────────────────────────────
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "rbBulkAssignAdd", "add_Checked", { $script:bulkAssignForm.DataContext.Action = "Add" })
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "rbBulkAssignReplace", "add_Checked", { $script:bulkAssignForm.DataContext.Action = "Replace" })
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "rbBulkAssignRemove", "add_Checked", { $script:bulkAssignForm.DataContext.Action = "Remove" })
# ── Assignments list — backing ObservableCollection so adds/removes show
# immediately. Each row carries display strings + the raw fields the
# public command consumes.
$script:colBulkAssignList = [System.Collections.ObjectModel.ObservableCollection[object]]::new()
$script:dgBulkAssignList.ItemsSource = $script:colBulkAssignList
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "btnBulkAssignAddGroup", "add_click", {
Show-BulkAssignmentGroupPicker
})
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "btnBulkAssignAddAllDev", "add_click", {
Add-BulkAssignmentRow -TargetType "allDevicesAssignmentTarget" -GroupName "All Devices"
})
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "btnBulkAssignAddAllUsers", "add_click", {
Add-BulkAssignmentRow -TargetType "allLicensedUsersAssignmentTarget" -GroupName "All Users"
})
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "btnBulkAssignSettings", "add_click", {
$sel = $script:dgBulkAssignList.SelectedItem
if(-not $sel) {
$script:UIProvider.ShowMessageBox("Select an assignment row first.", "Bulk Assignments", "OK", "Information") | Out-Null
return
}
Show-BulkAssignmentSettingsDialog -Row $sel
})
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "btnBulkAssignRemoveSel", "add_click", {
$sel = $script:dgBulkAssignList.SelectedItem
if($sel) { [void]$script:colBulkAssignList.Remove($sel) }
})
# Double-click opens the settings dialog (less destructive than remove).
$script:dgBulkAssignList.Add_MouseDoubleClick({
$sel = $script:dgBulkAssignList.SelectedItem
if($sel) { Show-BulkAssignmentSettingsDialog -Row $sel }
})
# ── Apply ─────────────────────────────────────────────────────────────────
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "btnBulkAssignApply", "add_click", {
$btnApply = $script:bulkAssignForm.FindName("btnBulkAssignApply")
$btnClose = $script:bulkAssignForm.FindName("btnBulkAssignClose")
$selectionIds = Get-BulkAssignSelectedObjectIds
$unit = if($script:bulkAssignMode -eq "Type") { "policy type" } else { "object group" }
if($selectionIds.Count -eq 0) {
$script:UIProvider.ShowMessageBox("Select at least one $unit to update.", "Bulk Assignments", "OK", "Warning") | Out-Null
return
}
$settings = $script:bulkAssignForm.DataContext
$settings.Assignments = @($script:colBulkAssignList)
if($settings.Assignments.Count -eq 0) {
$script:UIProvider.ShowMessageBox("Add at least one assignment target before applying.", "Bulk Assignments", "OK", "Warning") | Out-Null
return
}
# Mass-wipe guard: Replace with one row that isn't the actual desired
# final state still wipes everything else. Warn before continuing.
if($settings.Action -eq "Replace") {
$proceed = $script:UIProvider.ShowMessageBox(
"Replace will OVERWRITE every existing assignment on every matched policy with only the rows above ($($settings.Assignments.Count) target(s)).`n`nContinue?",
"Confirm replace", "YesNo", "Warning")
if($proceed -ne "Yes") { return }
}
$assignmentSummary = ($settings.Assignments | ForEach-Object {
$parts = @($_.TargetTypeDisplay, $_.GroupName)
if($_.FilterId) { $parts += "filter: $($_.FilterName) ($($_.FilterType))" }
($parts -join ' / ')
}) -join "`n "
$filterText = if($settings.Filter) { $settings.Filter } else { "(none)" }
$confirm = $script:UIProvider.ShowMessageBox(@"
About to update assignments on every policy that matches:
Action : $($settings.Action)
Assignments :
$assignmentSummary
Name filter : $filterText
$unit count : $($selectionIds.Count)
Continue?
"@, "Confirm Bulk Assignment update", "YesNo", "Warning")
if($confirm -ne "Yes") { return }
if($btnApply) { $btnApply.IsEnabled = $false }
if($btnClose) { $btnClose.IsEnabled = $false }
# No pre-call Write-Status: Set-GraphBulkAssignments now sets its own two-line
# status ("Bulk assignments — <Action>" + sub-step) as soon as it starts.
$startParams = @{ AssignmentSettings = $settings }
if($script:bulkAssignMode -eq "Type") { $startParams.PolicyType = $selectionIds }
else { $startParams.PolicyGroup = $selectionIds }
try {
$summary = Set-GraphBulkAssignments @startParams
Write-Status $null
$unsupportedNote = if($summary.UnsupportedTypes -and $summary.UnsupportedTypes.Count -gt 0) {
"`nUnsupported types skipped: $($summary.UnsupportedTypes -join ', ')"
} else { "" }
$msg = ("Scanned: {0}`nMatched filter: {1}`nUpdated: {2}`nNo change: {3}`nFailed: {4}`nDuration: {5:hh\:mm\:ss}{6}" -f `
$summary.PoliciesScanned, $summary.PoliciesMatched, $summary.PoliciesUpdated, $summary.PoliciesSkipped, $summary.PoliciesFailed, $summary.Duration, $unsupportedNote)
$script:txtBulkAssignStatus.Text = ("Last run: updated {0}, no change {1}, failed {2}" -f $summary.PoliciesUpdated, $summary.PoliciesSkipped, $summary.PoliciesFailed)
$unknownNote = Get-IntuneUnknownSelectorSummary $summary.UnknownSelectors
if($unknownNote) { $msg += "`n`n$unknownNote" }
$script:UIProvider.ShowMessageBox($msg, "Bulk Assignments", "OK", "Information") | Out-Null
try { $script:dgIntuneManagerObjects.Items.Refresh() }
catch { Write-LogDebug "Main grid refresh failed after bulk assignment run: $($_.Exception.Message)" }
}
catch {
Write-LogError "Bulk Assignments run failed" $_.Exception
$script:UIProvider.ShowMessageBox("Bulk Assignments run failed: $($_.Exception.Message)", "Bulk Assignments", "OK", "Error") | Out-Null
}
finally {
if($btnApply) { $btnApply.IsEnabled = $true }
if($btnClose) { $btnClose.IsEnabled = $true }
}
})
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "btnBulkAssignClose", "add_click", {
$script:bulkAssignForm = $null
$script:bulkAssignSelectAllHeader = $null
Show-ModalObject
})
$script:UIProvider.ShowModalForm("Bulk Assignments", $script:bulkAssignForm, $true)
}
# Rebuild the bulk-assign object DataGrid for the current view mode.
function Update-BulkAssignObjectList
{
if(-not $script:dgBulkAssignObjects) { return }
$script:bulkAssignObjects = @()
if($script:bulkAssignMode -eq "Type") {
$sorted = $script:bulkAssignEligibleTypes | Sort-Object Title
foreach($pt in $sorted) {
$script:bulkAssignObjects += New-Object PSObject -Property @{
Title = $pt.Title
Selected = $true
ObjectGroup = $null
ObjectType = $pt
}
}
}
else {
$sorted = $script:bulkAssignEligibleGroups | Sort-Object Title
foreach($grp in $sorted) {
$script:bulkAssignObjects += New-Object PSObject -Property @{
Title = $grp.Title
Selected = $true
ObjectGroup = $grp
ObjectType = $null
}
}
}
$script:dgBulkAssignObjects.ItemsSource = $script:bulkAssignObjects
}
function Get-BulkAssignSelectedObjectIds
{
if($script:bulkAssignMode -eq "Type") {
return @($script:bulkAssignObjects |
Where-Object { $_.Selected -eq $true -and $_.ObjectType -and $_.ObjectType.Id } |
ForEach-Object { $_.ObjectType.Id })
}
return @($script:bulkAssignObjects |
Where-Object { $_.Selected -eq $true -and $_.ObjectGroup -and $_.ObjectGroup.Id } |
ForEach-Object { $_.ObjectGroup.Id })
}
# Append a row to the assignments DataGrid. Used by All Devices / All Users
# buttons and by the group picker dialog after the user picks OK.
function Add-BulkAssignmentRow
{
param(
[Parameter(Mandatory)][string]$TargetType,
[string]$GroupId,
[string]$GroupName,
[string]$FilterId,
[string]$FilterName,
[string]$FilterType = "include",
# installIntent for mobileAppAssignment. Ignored by the public command
# for non-app types, so safe to set on every row.
[string]$Intent = "required"
)
# Display strings — keep the data layer plain (TargetType is the raw
# @odata.type fragment the public command uses; *Display columns are
# what the DataGrid binds to).
$typeDisplay = switch ($TargetType) {
"groupAssignmentTarget" { "Include group" }
"exclusionGroupAssignmentTarget" { "Exclude group" }
"allDevicesAssignmentTarget" { "All devices" }
"allLicensedUsersAssignmentTarget" { "All users" }
default { $TargetType }
}
$filterDisplay = if($FilterId) { "$FilterName ($FilterType)" } else { "" }
$row = [PSCustomObject]@{
TargetType = $TargetType
TargetTypeDisplay = $typeDisplay
GroupId = $GroupId
GroupName = if($GroupName) { $GroupName } else { $GroupId }
FilterId = $FilterId
FilterName = $FilterName
FilterType = $FilterType
FilterDisplay = $filterDisplay
Intent = $Intent
# Per-platform app assignment settings, keyed by Graph settings type
# name (e.g. "win32LobAppAssignmentSettings"). Absent key = no override
# for that type → Graph defaults are used on POST. Populated via the
# App settings... dialog (Show-BulkAssignmentSettingsDialog).
Settings = @{}
SettingsDisplay = "(none)"
}
# Dedupe — don't add the same target+intent twice. Two rows with the
# same target but different intents are intentionally allowed (Required
# vs Available is meaningful for apps).
$existing = @($script:colBulkAssignList | Where-Object {
$_.TargetType -eq $row.TargetType -and
[string]$_.GroupId -eq [string]$row.GroupId -and
[string]$_.FilterId -eq [string]$row.FilterId -and
([string]::IsNullOrEmpty($row.FilterId) -or $_.FilterType -eq $row.FilterType) -and
[string]$_.Intent -eq [string]$row.Intent
})
if($existing.Count -gt 0) {
$script:UIProvider.ShowMessageBox("That target is already in the list (with the same intent).", "Bulk Assignments", "OK", "Information") | Out-Null
return
}
[void]$script:colBulkAssignList.Add($row)
}
# ─── Bulk Assignments — per-platform settings ────────────────────────────────
#
# Schema for each settings type lives in $script:_bulkAssignSettingsSchema —
# one entry per TabItem in BulkAssignmentsSettings.xaml. Drives both the load
# (Row.Settings → UI controls) and save (UI controls → Row.Settings) passes
# from one piece of code per direction.
#
# Field types:
# bool — CheckBox.IsChecked
# string — TextBox.Text (empty skipped)
# int — TextBox.Text → int (empty skipped, non-numeric skipped)
# enum — ComboBox.SelectedItem (string)
# datetime — TextBox.Text passed through as-is (Graph accepts ISO8601)
# stringList — TextBox.Text split on newline / semicolon / comma (empties skipped)
#
# Nested complex types (win32LobAppRestartSettings, etc.) are encoded under a
# Nested array on the tab descriptor; the saved hashtable gets a nested
# hashtable + @odata.type stamp.
$script:_bulkAssignSettingsSchema = @(
@{ Apply = "chkApplyIosLob"; Type = "iosLobAppAssignmentSettings";
Fields = @(
@{ Control="chkIosLob_IsRemovable"; Key="isRemovable"; Kind="bool" }
@{ Control="chkIosLob_PreventBackup"; Key="preventManagedAppBackup"; Kind="bool" }
@{ Control="chkIosLob_Uninstall"; Key="uninstallOnDeviceRemoval"; Kind="bool" }
@{ Control="txtIosLob_VpnId"; Key="vpnConfigurationId"; Kind="string" }
)}
@{ Apply = "chkApplyIosStore"; Type = "iosStoreAppAssignmentSettings";
Fields = @(
@{ Control="chkIosStore_IsRemovable"; Key="isRemovable"; Kind="bool" }
@{ Control="chkIosStore_PreventBackup"; Key="preventManagedAppBackup"; Kind="bool" }
@{ Control="chkIosStore_Uninstall"; Key="uninstallOnDeviceRemoval"; Kind="bool" }
@{ Control="txtIosStore_VpnId"; Key="vpnConfigurationId"; Kind="string" }
)}
@{ Apply = "chkApplyIosVpp"; Type = "iosVppAppAssignmentSettings";
Fields = @(
@{ Control="chkIosVpp_DeviceLicensing"; Key="useDeviceLicensing"; Kind="bool" }
@{ Control="chkIosVpp_IsRemovable"; Key="isRemovable"; Kind="bool" }
@{ Control="chkIosVpp_PreventBackup"; Key="preventManagedAppBackup"; Kind="bool" }
@{ Control="chkIosVpp_PreventAutoUpdate"; Key="preventAutoAppUpdate"; Kind="bool" }
@{ Control="chkIosVpp_Uninstall"; Key="uninstallOnDeviceRemoval"; Kind="bool" }
@{ Control="txtIosVpp_VpnId"; Key="vpnConfigurationId"; Kind="string" }
)}
@{ Apply = "chkApplyIosDdm"; Type = "iosDdmLobAppAssignmentSettings";
Fields = @(
@{ Control="txtIosDdm_Domains"; Key="associatedDomains"; Kind="stringList" }
@{ Control="chkIosDdm_DirectDownload"; Key="associatedDomainsDirectDownloadAllowed"; Kind="bool" }
@{ Control="chkIosDdm_PreventBackup"; Key="preventManagedAppBackup"; Kind="bool" }
@{ Control="chkIosDdm_TapToPay"; Key="tapToPayScreenLockEnabled"; Kind="bool" }
@{ Control="txtIosDdm_VpnId"; Key="vpnConfigurationId"; Kind="string" }
)}
@{ Apply = "chkApplyAndroidStore"; Type = "androidManagedStoreAppAssignmentSettings";
Fields = @(
@{ Control="cbAndroidStore_AutoUpdate"; Key="autoUpdateMode"; Kind="enum" }
@{ Control="txtAndroidStore_Tracks"; Key="androidManagedStoreAppTrackIds"; Kind="stringList" }
)}
@{ Apply = "chkApplyMacLob"; Type = "macOsLobAppAssignmentSettings";
Fields = @(
@{ Control="chkMacLob_Uninstall"; Key="uninstallOnDeviceRemoval"; Kind="bool" }
)}
@{ Apply = "chkApplyMacVpp"; Type = "macOsVppAppAssignmentSettings";
Fields = @(
@{ Control="chkMacVpp_DeviceLicensing"; Key="useDeviceLicensing"; Kind="bool" }
@{ Control="chkMacVpp_PreventBackup"; Key="preventManagedAppBackup"; Kind="bool" }
@{ Control="chkMacVpp_PreventAutoUpdate"; Key="preventAutoAppUpdate"; Kind="bool" }
@{ Control="chkMacVpp_Uninstall"; Key="uninstallOnDeviceRemoval"; Kind="bool" }
)}
@{ Apply = "chkApplyMsStore"; Type = "microsoftStoreForBusinessAppAssignmentSettings";
Fields = @(
@{ Control="chkMsStore_DeviceContext"; Key="useDeviceContext"; Kind="bool" }
)}
@{ Apply = "chkApplyWinAppx"; Type = "windowsAppXAppAssignmentSettings";
Fields = @(
@{ Control="chkWinAppx_DeviceContext"; Key="useDeviceContext"; Kind="bool" }
)}
@{ Apply = "chkApplyWinUap"; Type = "windowsUniversalAppXAppAssignmentSettings";
Fields = @(
@{ Control="chkWinUap_DeviceContext"; Key="useDeviceContext"; Kind="bool" }
)}
@{ Apply = "chkApplyWin32"; Type = "win32LobAppAssignmentSettings";
Fields = @(
@{ Control="cbWin32_DeliveryOpt"; Key="deliveryOptimizationPriority"; Kind="enum" }
@{ Control="cbWin32_Notifications"; Key="notifications"; Kind="enum" }
)
Nested = @(
@{ Key="autoUpdateSettings"; Type="win32LobAppAutoUpdateSettings";
Fields=@(
@{ Control="cbWin32_Superseded"; Key="autoUpdateSupersededAppsState"; Kind="enum" }
)}
@{ Key="installTimeSettings"; Type="mobileAppInstallTimeSettings";
Fields=@(
@{ Control="chkWin32_UseLocalTime"; Key="useLocalTime"; Kind="bool" }
@{ Control="txtWin32_StartTime"; Key="startDateTime"; Kind="datetime" }
@{ Control="txtWin32_DeadlineTime"; Key="deadlineDateTime"; Kind="datetime" }
)}
@{ Key="restartSettings"; Type="win32LobAppRestartSettings";
Fields=@(
@{ Control="txtWin32_GracePeriod"; Key="gracePeriodInMinutes"; Kind="int" }
@{ Control="txtWin32_Countdown"; Key="countdownDisplayBeforeRestartInMinutes"; Kind="int" }
)}
)}
@{ Apply = "chkApplyWinGet"; Type = "winGetAppAssignmentSettings";
Fields = @(
@{ Control="cbWinGet_Notifications"; Key="notifications"; Kind="enum" }
)
Nested = @(
@{ Key="installTimeSettings"; Type="winGetAppInstallTimeSettings";
Fields=@(
@{ Control="chkWinGet_UseLocalTime"; Key="useLocalTime"; Kind="bool" }
@{ Control="txtWinGet_DeadlineTime"; Key="deadlineDateTime"; Kind="datetime" }
)}
@{ Key="restartSettings"; Type="winGetAppRestartSettings";
Fields=@(
@{ Control="txtWinGet_GracePeriod"; Key="gracePeriodInMinutes"; Kind="int" }
@{ Control="txtWinGet_Countdown"; Key="countdownDisplayBeforeRestartInMinutes"; Kind="int" }
)}
)}
@{ Apply = "chkApplyWinAutoUpdate"; Type = "windowsAutoUpdateCatalogAppAssignmentSettings";
Fields = @(
@{ Control="cbWinAutoUpdate_DeliveryOpt"; Key="deliveryOptimizationPriority"; Kind="enum" }
@{ Control="cbWinAutoUpdate_Notifications"; Key="notificationType"; Kind="enum" }
)
Nested = @(
@{ Key="installTimeSettings"; Type="windowsAutoUpdateCatalogAppInstallTimeSettings";
Fields=@(
@{ Control="chkWinAutoUpdate_UseLocalTime"; Key="useLocalTime"; Kind="bool" }
@{ Control="txtWinAutoUpdate_StartTime"; Key="startDateTime"; Kind="datetime" }
@{ Control="txtWinAutoUpdate_DeadlineTime"; Key="deadlineDateTime"; Kind="datetime" }
)}
@{ Key="restartSettings"; Type="windowsAutoUpdateCatalogAppRestartSettings";
Fields=@(
@{ Control="txtWinAutoUpdate_GracePeriod"; Key="gracePeriodInMinutes"; Kind="int" }
)}
)}
)
# Pull a field value out of a control and convert to the right Graph type.
# Returns $null when the field has no usable value (empty string, no
# selection, parse failure) — caller skips the key entirely so Graph keeps
# its default rather than receiving an empty/zero value.
function Get-BulkAssignmentSettingValue
{
param($Form, $Field)
$ctl = $Form.FindName($Field.Control)
if(-not $ctl) { return $null }
switch ($Field.Kind) {
"bool" { return [bool]$ctl.IsChecked }
"string" {
$v = [string]$ctl.Text
if([string]::IsNullOrWhiteSpace($v)) { return $null }
return $v
}
"int" {
$v = [string]$ctl.Text
if([string]::IsNullOrWhiteSpace($v)) { return $null }
$parsed = 0
if([int]::TryParse($v, [ref]$parsed)) { return $parsed }
return $null
}
"enum" {
$v = [string]$ctl.SelectedItem
if([string]::IsNullOrWhiteSpace($v)) { return $null }
return $v
}
"datetime" {
$v = [string]$ctl.Text
if([string]::IsNullOrWhiteSpace($v)) { return $null }
return $v # Graph accepts ISO8601 as a string in the JSON body
}
"stringList" {
$v = [string]$ctl.Text
if([string]::IsNullOrWhiteSpace($v)) { return $null }
$items = @($v -split "[`r`n;,]+" | ForEach-Object { $_.Trim() } | Where-Object { $_ })
if($items.Count -eq 0) { return $null }
return ,$items
}
}
return $null
}
# Push a stored hashtable value back into the UI control. Inverse of
# Get-BulkAssignmentSettingValue. Tolerates missing keys (control left at
# its XAML-default state).
function Set-BulkAssignmentSettingValue
{
param($Form, $Field, $Value)
$ctl = $Form.FindName($Field.Control)
if(-not $ctl) { return }
switch ($Field.Kind) {
"bool" { if($null -ne $Value) { $ctl.IsChecked = [bool]$Value } }
"string" { if($null -ne $Value) { $ctl.Text = [string]$Value } }
"int" { if($null -ne $Value) { $ctl.Text = [string]$Value } }
"enum" { if($null -ne $Value) { $ctl.SelectedItem = [string]$Value } }
"datetime" { if($null -ne $Value) { $ctl.Text = [string]$Value } }
"stringList" {
if($null -ne $Value) {
$ctl.Text = (@($Value) -join [Environment]::NewLine)
}
}
}
}
function Show-BulkAssignmentSettingsDialog
{
param([Parameter(Mandatory)]$Row)
$form = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\BulkAssignmentsSettings.xaml"))
if(-not $form) { return }
$script:_bulkAssignSettingsForm = $form
$script:_bulkAssignSettingsRow = $Row
# Populate enum combos once. Lists come from the Graph CSDL — keep them
# in lock-step with the metadata when Microsoft adds new enum members.
$enumLists = @{
cbAndroidStore_AutoUpdate = @("default","postponed","priority")
cbWin32_DeliveryOpt = @("notConfigured","foreground")
cbWin32_Notifications = @("showAll","showReboot","hideAll")
cbWin32_Superseded = @("notConfigured","enabled")
cbWinGet_Notifications = @("showAll","showReboot","hideAll")
cbWinAutoUpdate_DeliveryOpt = @("notConfigured","foreground")
cbWinAutoUpdate_Notifications = @("showAll","showReboot","hideAll")
cbScript_ScheduleType = @("Hourly","Daily","Once")
}
foreach($name in $enumLists.Keys) {
$ctl = $form.FindName($name)
if($ctl) { $ctl.ItemsSource = $enumLists[$name] }
}
# Custom load: Health Script tab uses a polymorphic runSchedule whose
# @odata.type depends on ScheduleType. Stored under the synthetic
# "deviceHealthScriptAssignment" key in Row.Settings (not a Graph type
# name — just an internal marker the public command consumes).
if($Row.Settings -and $Row.Settings.ContainsKey('deviceHealthScriptAssignment')) {
$hs = $Row.Settings['deviceHealthScriptAssignment']
if($hs) {
$form.FindName('chkApplyHealthScript').IsChecked = $true
if($hs.ContainsKey('runRemediationScript')) {
$form.FindName('chkScript_RunRemediation').IsChecked = [bool]$hs['runRemediationScript']
}
if($hs.ContainsKey('scheduleType')) { $form.FindName('cbScript_ScheduleType').SelectedItem = [string]$hs['scheduleType'] }
if($hs.ContainsKey('interval')) { $form.FindName('txtScript_Interval').Text = [string]$hs['interval'] }
if($hs.ContainsKey('time')) { $form.FindName('txtScript_Time').Text = [string]$hs['time'] }
if($hs.ContainsKey('useUtc')) { $form.FindName('chkScript_UseUtc').IsChecked = [bool]$hs['useUtc'] }
if($hs.ContainsKey('date')) { $form.FindName('txtScript_Date').Text = [string]$hs['date'] }
}
}
# Load existing values from Row.Settings into the controls.
foreach($tab in $script:_bulkAssignSettingsSchema) {
$existing = $null
if($Row.Settings -and $Row.Settings.ContainsKey($tab.Type)) {
$existing = $Row.Settings[$tab.Type]
}
if(-not $existing) { continue }
$applyCtl = $form.FindName($tab.Apply)
if($applyCtl) { $applyCtl.IsChecked = $true }
foreach($field in $tab.Fields) {
if($existing.ContainsKey($field.Key)) {
Set-BulkAssignmentSettingValue $form $field $existing[$field.Key]
}
}
if($tab.Nested) {
foreach($nested in $tab.Nested) {
if(-not $existing.ContainsKey($nested.Key)) { continue }
$nestedHash = $existing[$nested.Key]
if(-not $nestedHash) { continue }
foreach($field in $nested.Fields) {
if($nestedHash.ContainsKey($field.Key)) {
Set-BulkAssignmentSettingValue $form $field $nestedHash[$field.Key]
}
}
}
}
}
$script:UIProvider.AddXamlEvent($form, "btnBulkAssignSettingsOK", "add_click", {
$r = $script:_bulkAssignSettingsRow
$f = $script:_bulkAssignSettingsForm
$new = @{}
foreach($tab in $script:_bulkAssignSettingsSchema) {
$applyCtl = $f.FindName($tab.Apply)
if(-not $applyCtl -or -not $applyCtl.IsChecked) { continue }
$hash = @{}
foreach($field in $tab.Fields) {
$v = Get-BulkAssignmentSettingValue $f $field
if($null -ne $v) { $hash[$field.Key] = $v }
}
if($tab.Nested) {
foreach($nested in $tab.Nested) {
$nestedHash = @{}
foreach($field in $nested.Fields) {
$v = Get-BulkAssignmentSettingValue $f $field
if($null -ne $v) { $nestedHash[$field.Key] = $v }
}
if($nestedHash.Count -gt 0) {
$nestedHash["@odata.type"] = "#microsoft.graph.$($nested.Type)"
$hash[$nested.Key] = $nestedHash
}
}
}
# Only include the tab if SOMETHING is set. Otherwise it's just
# the apply-checkbox ticked with all defaults — no value to send.
if($hash.Count -gt 0) { $new[$tab.Type] = $hash }
}
# Custom save: Health Script tab. Stored as a flat hashtable; the
# public command picks the schedule @odata.type at POST time from
# the scheduleType value here.
$applyHs = $f.FindName('chkApplyHealthScript')
if($applyHs -and $applyHs.IsChecked) {
$hsHash = @{
runRemediationScript = [bool]$f.FindName('chkScript_RunRemediation').IsChecked
}
$st = [string]$f.FindName('cbScript_ScheduleType').SelectedItem
if($st) {
$hsHash['scheduleType'] = $st
$intervalText = [string]$f.FindName('txtScript_Interval').Text
$parsedInterval = 0
if(-not [string]::IsNullOrWhiteSpace($intervalText) -and [int]::TryParse($intervalText, [ref]$parsedInterval)) {
$hsHash['interval'] = $parsedInterval
}
if($st -in @('Daily','Once')) {
$timeText = [string]$f.FindName('txtScript_Time').Text
if(-not [string]::IsNullOrWhiteSpace($timeText)) { $hsHash['time'] = $timeText.Trim() }
$hsHash['useUtc'] = [bool]$f.FindName('chkScript_UseUtc').IsChecked
}
if($st -eq 'Once') {
$dateText = [string]$f.FindName('txtScript_Date').Text
if(-not [string]::IsNullOrWhiteSpace($dateText)) { $hsHash['date'] = $dateText.Trim() }
}
}
$new['deviceHealthScriptAssignment'] = $hsHash
}
$r.Settings = $new
$r.SettingsDisplay = if($new.Count -gt 0) { "$($new.Count) platform(s)" } else { "(none)" }
try { $script:dgBulkAssignList.Items.Refresh() } catch { }
$script:_bulkAssignSettingsForm = $null
$script:_bulkAssignSettingsRow = $null
Close-TopModalObject
})
$script:UIProvider.AddXamlEvent($form, "btnBulkAssignSettingsCancel", "add_click", {
$script:_bulkAssignSettingsForm = $null
$script:_bulkAssignSettingsRow = $null
Close-TopModalObject
})
$script:UIProvider.ShowModalForm("App settings - $($Row.GroupName)", $form, $true)
}
# Group-picker dialog. Searches AAD groups via Graph (startsWith), lets the
# user pick include/exclude direction and optionally attach a deviceAnd
# AppManagementAssignmentFilter. Reuses the global modal scaffolding so the
# parent Bulk Assignments form stays put underneath.
function Show-BulkAssignmentGroupPicker
{
$picker = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\BulkAssignmentsGroupPicker.xaml"))
if(-not $picker) { return }
$script:_bulkAssignGroupPicker = $picker
$txtSearch = $picker.FindName("txtGroupSearch")
$btnSearch = $picker.FindName("btnGroupSearch")
$lstResults = $picker.FindName("lstGroupResults")
$cbFilter = $picker.FindName("cbGroupFilter")
$cbIntent = $picker.FindName("cbGroupIntent")
if(-not $txtSearch -or -not $btnSearch -or -not $lstResults -or -not $cbFilter -or -not $cbIntent) {
Write-Log "Bulk Assignments group picker XAML loaded, but required controls were not found" 3
return
}
# Populate assignment filters once. Empty entry = no filter.
$tokenId = Get-DefaultTokenId
$filters = @([PSCustomObject]@{ Id = $null; Name = "(no filter)" })
try {
$resp = Invoke-MSGraphAPI -Url "deviceManagement/assignmentFilters" -TokenId $tokenId -AllPages
if($resp -and $resp.value) {
foreach($f in @($resp.value | Sort-Object displayName)) {
$filters += [PSCustomObject]@{ Id = [string]$f.id; Name = [string]$f.displayName }
}
}
}
catch { Write-LogDebug "Bulk Assignments: failed to load assignment filters: $($_.Exception.Message)" }
$cbFilter.ItemsSource = $filters
$cbFilter.SelectedIndex = 0
# installIntent values from Graph's mobileAppAssignment schema. Required
# is the most common bulk action so make it the default.
$intents = @(
[PSCustomObject]@{ Name = "Required"; Value = "required" }
[PSCustomObject]@{ Name = "Available"; Value = "available" }
[PSCustomObject]@{ Name = "Uninstall"; Value = "uninstall" }
[PSCustomObject]@{ Name = "Available without enrollment"; Value = "availableWithoutEnrollment" }
[PSCustomObject]@{ Name = "Not available (hidden)"; Value = "notAvailable" }
)
$cbIntent.ItemsSource = $intents
$cbIntent.SelectedValue = "required"
$searchAction = {
$activePicker = $script:_bulkAssignGroupPicker
$searchBox = if($activePicker) { $activePicker.FindName("txtGroupSearch") } else { $null }
$resultsList = if($activePicker) { $activePicker.FindName("lstGroupResults") } else { $null }
if(-not $searchBox -or -not $resultsList -or -not ($resultsList -is [System.Windows.Controls.ItemsControl])) {
Write-Log "Bulk Assignments: group search controls were not available" 3
return
}
$term = [string]$searchBox.Text
if($term.Length -lt 1) {
$resultsList.ItemsSource = $null
return
}
$escaped = $term.Replace("'", "''")
$url = "groups?`$top=25&`$select=id,displayName&`$filter=startswith(displayName,'$escaped')"
try {
$resp = Invoke-MSGraphAPI -Url $url -TokenId (Get-DefaultTokenId)
if($resp -and $resp.value) {
$resultsList.ItemsSource = @($resp.value | Sort-Object displayName)
}
else {
$resultsList.ItemsSource = @()
}
}
catch {
Write-LogError "Bulk Assignments: group search failed" $_.Exception
$resultsList.ItemsSource = @()
}
}
$btnSearch.Add_Click($searchAction)
$txtSearch.Add_KeyDown({
param($src, $e)
if($e.Key -eq "Return") { & $searchAction }
})
$script:UIProvider.AddXamlEvent($picker, "btnGroupPickerOK", "add_click", {
$sel = $script:_bulkAssignGroupPicker.FindName("lstGroupResults").SelectedItem
if(-not $sel) {
$script:UIProvider.ShowMessageBox("Pick a group from the search results first.", "Bulk Assignments", "OK", "Warning") | Out-Null
return
}
$isExclude = [bool]$script:_bulkAssignGroupPicker.FindName("rbGroupExclude").IsChecked
$targetType = if($isExclude) { "exclusionGroupAssignmentTarget" } else { "groupAssignmentTarget" }
$filterItem = $script:_bulkAssignGroupPicker.FindName("cbGroupFilter").SelectedItem
$filterId = if($filterItem) { [string]$filterItem.Id } else { $null }
$filterName = if($filterItem -and $filterId) { [string]$filterItem.Name } else { $null }
$filterType = "include"
if($filterId -and [bool]$script:_bulkAssignGroupPicker.FindName("rbGroupFilterExclude").IsChecked) {
$filterType = "exclude"
}
$intent = [string]$script:_bulkAssignGroupPicker.FindName("cbGroupIntent").SelectedValue
if(-not $intent) { $intent = "required" }
Add-BulkAssignmentRow `
-TargetType $targetType `
-GroupId ([string]$sel.id) `
-GroupName ([string]$sel.displayName) `
-FilterId $filterId `
-FilterName $filterName `
-FilterType $filterType `
-Intent $intent
$script:_bulkAssignGroupPicker = $null
Close-TopModalObject
})
$script:UIProvider.AddXamlEvent($picker, "btnGroupPickerCancel", "add_click", {
$script:_bulkAssignGroupPicker = $null
Close-TopModalObject
})
$script:UIProvider.ShowModalForm("Pick a group", $picker, $true)
}