# Avalonia port of UI/WPF/Extensions/IntuneManagerUI.ps1::Show-IntuneManagerDetailedView # (lines 666-1135 in WPF). Lives in its own file per architecture R9 — Details # is a self-contained subsystem and the WPF original sits in the giant # IntuneManagerUI file we're trying not to grow further. # # Slice 3f: ObjectDetails.axaml + Show-IntuneManagerDetailedView. # - JSON tab: txtValue + Load full + Copy # - Settings tab: name + description + scope-tag dual-list + Save (PATCH) # - Columns tab: Basic/Object property pickers + ObjectColumnInfo editor # + Up/Down/Delete/Clear/Override + Reset/Save # # Per-policy-type AddUIDetailsExtension is invoked at the bottom — Avalonia # variants live in UI/Avalonia/ClassExtensions/Intune*ClassUIExtension*.ps1 # and route through Add-AvaloniaDetailsButton (see # Extensions/IntuneManagerExtensionHooks.ps1) instead of building WPF # controls directly. # # Dirty-tracking signatures + Confirm-DetailsViewClose work the same as WPF, # but read from the captured $state hashtable rather than $script: vars so # multiple modal opens don't trample each other. function Show-IntuneManagerDetailedView { param( $FormTitle = "", [switch]$NoLoadFull ) $ui = $script:UIProvider if (-not $script:dgIntuneManagerObjects) { return } $selectedRow = $script:dgIntuneManagerObjects.SelectedItem if (-not $selectedRow -and $script:dgIntuneManagerObjects.ItemsSource) { $selectedRow = @($script:dgIntuneManagerObjects.ItemsSource | Where-Object { $_.PSObject.Properties['IsSelected'] -and $_.IsSelected -eq $true } | Select-Object -First 1) if ($selectedRow.Count -gt 0) { $selectedRow = $selectedRow[0] } } if (-not $selectedRow) { return } # IntuneObjectRowItem.Source holds the underlying IntunePolicyBase, which # is what JsonObject / Object / Get() etc. live on. WPF read directly from # the row because rows there were the IntunePolicyBase itself. $selectedItem = $selectedRow.Source if (-not $selectedItem -or -not $selectedItem.JsonObject) { Write-Log "View: selected row carries no policy object" 3 return } $detailsForm = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/ObjectDetails.axaml')) if (-not $detailsForm) { return } $hostType = Get-AvaloniaHost # State captured in closures rather than $script: vars so reopening the # dialog doesn't trample a still-running instance and so the close # confirmation sees the right form. $state = @{ Form = $detailsForm SelectedItem = $selectedItem ScopeTagPropName = $null AllScopeTags = @() AssignedIds = [System.Collections.Generic.HashSet[string]]::new() AvailableCollection = [System.Collections.ObjectModel.ObservableCollection[object]]::new() AssignedCollection = [System.Collections.ObjectModel.ObservableCollection[object]]::new() ColObjectProperties = [System.Collections.ObjectModel.ObservableCollection[object]]::new() InitialSettingsSig = "" InitialColumnsSig = "" LstAvail = $null LstAssigned = $null TxtValue = $null LstBasic = $null LstObjProps = $null LstObjCols = $null GrdObjCols = $null ChkOverride = $null HostType = $null } # Event handlers must reach this through $script: - closure captures do # not survive ConvertTo-AvaloniaEventScriptBlock (NewBoundScriptBlock # rebinds to the module and drops the .GetNewClosure() state). $script:_detailsTagState = $state if (-not $FormTitle) { $FormTitle = $selectedItem.PolicyName } if ($selectedItem.Name) { $FormTitle = "$FormTitle - $($selectedItem.Name)" } # ---- JSON tab ---------------------------------------------------------- $txtValue = $hostType::FindByName($detailsForm, 'txtValue') $state.TxtValue = $txtValue $state.HostType = $hostType $btnFull = $hostType::FindByName($detailsForm, 'btnFull') $btnCopy = $hostType::FindByName($detailsForm, 'btnCopy') if ($txtValue) { $txtValue.Text = [string]$selectedItem.JsonString } if ($btnFull -and $selectedItem.PolicyType.AllowFullDetails -eq $false) { $btnFull.IsVisible = $false } if ($btnCopy) { $btnCopy.add_Click((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_detailsTagState if (-not $st) { return } try { $st = $script:_detailsTagState if ($st -and $st.TxtValue -and $st.TxtValue.Text) { $st.TxtValue.Text | Set-Clipboard } } catch { Write-LogError "Details Copy failed" $_.Exception } })) } if ($btnFull) { $btnFull.add_Click((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_detailsTagState if (-not $st) { return } try { $st = $script:_detailsTagState if (-not $st -or -not $st.SelectedItem) { return } Write-Status "Get full object $($st.SelectedItem.Name)" [void]$st.SelectedItem.Get() if ($st.SelectedItem.IsFullObject -and $st.TxtValue) { $st.TxtValue.Text = [string]$st.SelectedItem.JsonString } Write-Status "" } catch { Write-LogError "Load full failed" $_.Exception; Write-Status "" } })) } # ---- Settings tab ------------------------------------------------------ $txtName = $hostType::FindByName($detailsForm, 'txtObjectName') $txtDesc = $hostType::FindByName($detailsForm, 'txtObjectDescription') if ($txtName) { $txtName.Text = [string]$selectedItem.Name } if ($txtDesc) { $txtDesc.Text = [string]$selectedItem.Description } # Snapshots for the save-confirmation dialog: it names the object and # says exactly what changed, computed against these open-time values via # $script: state (reliable regardless of handler capture behavior). $state.TxtName = $txtName $state.TxtDesc = $txtDesc $state.OriginalName = if ($txtName) { [string]$txtName.Text } else { [string]$selectedItem.Name } $state.OriginalDesc = if ($txtDesc) { [string]$txtDesc.Text } else { '' } $pnlScopeTagsLabel = $hostType::FindByName($detailsForm, 'pnlScopeTagsLabel') $grdScopeTags = $hostType::FindByName($detailsForm, 'grdScopeTags') $lstAvail = $hostType::FindByName($detailsForm, 'lstAvailableScopeTags') $lstAssigned = $hostType::FindByName($detailsForm, 'lstAssignedScopeTags') $btnTagAssign = $hostType::FindByName($detailsForm, 'btnScopeTagAssign') $btnTagUnassign = $hostType::FindByName($detailsForm, 'btnScopeTagUnassign') $btnSettingsSave = $hostType::FindByName($detailsForm, 'btnObjectSettingsSave') if ($selectedItem.PolicyType.ScopeTagProperty) { $state.ScopeTagPropName = [string]$selectedItem.PolicyType.ScopeTagProperty } if (-not $state.ScopeTagPropName) { if ($pnlScopeTagsLabel) { $pnlScopeTagsLabel.IsVisible = $false } if ($grdScopeTags) { $grdScopeTags.IsVisible = $false } } else { $allScopeTags = @() try { $depObjects = Get-GraphDependencySourceObjects $selectedItem -DefaultPoliciesOnly if ($depObjects -and $depObjects.ContainsKey('ScopeTags')) { $allScopeTags = @($depObjects['ScopeTags']) } } catch { Write-LogDebug "Scope tag dependency cache lookup failed: $($_.Exception.Message)" } if ($allScopeTags.Count -eq 0) { try { $allScopeTags = @(Get-GraphPolicies -PolicyType 'ScopeTags' -TokenId $selectedItem._TokenId) } catch { Write-LogError "Failed to load scope tags" $_.Exception } } $currentIds = $null if ($selectedItem.Object -and $selectedItem.Object.PSObject.Properties[$state.ScopeTagPropName]) { $currentIds = $selectedItem.Object.($state.ScopeTagPropName) } foreach ($id in @($currentIds)) { if ($null -ne $id) { [void]$state.AssignedIds.Add([string]$id) } } $state.OriginalAssignedIds = [System.Collections.Generic.HashSet[string]]::new($state.AssignedIds) # Project to CopyDialogScopeTagItem (already a CLR class with Id/Name) — # same shape works here. Dedupe by Id (cache may carry both the # synthetic Default and a real Default tag). $dedup = @() $seenIds = [System.Collections.Generic.HashSet[string]]::new() foreach ($tag in $allScopeTags) { $idStr = [string]$tag.Id if (-not $seenIds.Add($idStr)) { continue } $dedup += [CopyDialogScopeTagItem]@{ Id = $idStr; Name = [string]$tag.Name } } $state.AllScopeTags = @($dedup | Sort-Object Name) if ($lstAvail) { $lstAvail.ItemsSource = $state.AvailableCollection } if ($lstAssigned) { $lstAssigned.ItemsSource = $state.AssignedCollection } $state.LstAvail = $lstAvail $state.LstAssigned = $lstAssigned # Helpers live in $script: and read $script: state only - local # captures would be lost the moment an event handler runs them. $script:_detailsSyncTagLists = { $st = $script:_detailsTagState if (-not $st) { return } $st.AvailableCollection.Clear() $st.AssignedCollection.Clear() foreach ($tag in $st.AllScopeTags) { if ($st.AssignedIds.Contains($tag.Id)) { [void]$st.AssignedCollection.Add($tag) } else { [void]$st.AvailableCollection.Add($tag) } } } & $script:_detailsSyncTagLists $script:_detailsMoveTags = { param([string]$Action, [string[]]$Ids) $st = $script:_detailsTagState if (-not $st -or $Ids.Count -eq 0) { return } if ($Action -eq 'assign') { foreach ($id in $Ids) { [void]$st.AssignedIds.Add([string]$id) } } else { foreach ($id in $Ids) { [void]$st.AssignedIds.Remove([string]$id) } } & $script:_detailsSyncTagLists } if ($btnTagAssign) { $btnTagAssign.add_Click((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_detailsTagState if (-not $st) { return } try { $st = $script:_detailsTagState if (-not $st -or -not $st.LstAvail -or $st.LstAvail.SelectedItems.Count -eq 0) { Write-Status "Select one or more scope tag(s) in the Available list first" return } $ids = @($st.LstAvail.SelectedItems | ForEach-Object { [string]$_.Id }) & $script:_detailsMoveTags 'assign' $ids } catch { Write-LogError "Scope-tag assign handler failed" $_.Exception } })) } if ($btnTagUnassign) { $btnTagUnassign.add_Click((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_detailsTagState if (-not $st) { return } try { $st = $script:_detailsTagState if (-not $st -or -not $st.LstAssigned -or $st.LstAssigned.SelectedItems.Count -eq 0) { Write-Status "Select one or more scope tag(s) in the Assigned list first" return } $ids = @($st.LstAssigned.SelectedItems | ForEach-Object { [string]$_.Id }) & $script:_detailsMoveTags 'unassign' $ids } catch { Write-LogError "Scope-tag unassign handler failed" $_.Exception } })) } # Avalonia's MouseDoubleClick equivalent on ListBox is DoubleTapped. if ($lstAvail) { $lstAvail.add_DoubleTapped((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_detailsTagState if (-not $st) { return } try { $st = $script:_detailsTagState if (-not $st -or -not $st.LstAvail -or $st.LstAvail.SelectedItems.Count -eq 0) { return } $ids = @($st.LstAvail.SelectedItems | ForEach-Object { [string]$_.Id }) & $script:_detailsMoveTags 'assign' $ids } catch { Write-LogError "Scope-tag double-tap (available) failed" $_.Exception } })) } if ($lstAssigned) { $lstAssigned.add_DoubleTapped((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_detailsTagState if (-not $st) { return } try { $st = $script:_detailsTagState if (-not $st -or -not $st.LstAssigned -or $st.LstAssigned.SelectedItems.Count -eq 0) { return } $ids = @($st.LstAssigned.SelectedItems | ForEach-Object { [string]$_.Id }) & $script:_detailsMoveTags 'unassign' $ids } catch { Write-LogError "Scope-tag double-tap (assigned) failed" $_.Exception } })) } } if ($btnSettingsSave) { $btnSettingsSave.add_Click((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_detailsTagState if (-not $st) { return } try { # Name the object and say what changed - all read from the # $script: state snapshots taken at form open. $st = $script:_detailsTagState $originalName = if ($st -and $st.OriginalName) { [string]$st.OriginalName } else { [string]$st.SelectedItem.Name } $changes = @() if ($st -and $st.TxtName) { $newName = [string]$st.TxtName.Text if ($newName -and $newName -cne $originalName) { $changes += "New name: '$newName'" } } if ($st -and $st.TxtDesc -and ([string]$st.TxtDesc.Text) -cne [string]$st.OriginalDesc) { $changes += "Description updated" } if ($st -and $null -ne $st.OriginalAssignedIds -and $null -ne $st.AssignedIds) { $added = @($st.AssignedIds | Where-Object { -not $st.OriginalAssignedIds.Contains($_) }).Count $removed = @($st.OriginalAssignedIds | Where-Object { -not $st.AssignedIds.Contains($_) }).Count if ($added -or $removed) { $changes += "Scope tags: $added added, $removed removed" } } $confirmMsg = "Are you sure you want to update '$originalName'?" if ($changes.Count) { $confirmMsg += "`n`n" + ($changes -join "`n") } if (($ui.ShowMessageBox($confirmMsg, "Update object information?", "YesNo", "Warning")) -ne "Yes") { return } Write-Status "Update object information for $($st.SelectedItem.Name)" $nameValue = if ($st.TxtName) { [string]$st.TxtName.Text } else { '' } if (-not $nameValue) { $ui.ShowMessageBox("Name property must not be empty!", "Error", "OK", "Error") Write-Status "" return } $nameProp = (?? $st.SelectedItem.PolicyType.NameProperty "displayName") $idProp = (?? $st.SelectedItem.PolicyType.IDProperty "id") $updateHT = @{} $updateHT.Add($idProp, $st.SelectedItem.ID) $updateHT.Add($nameProp, $nameValue) if ($st.SelectedItem.Object."@odata.type") { $updateHT.Add("@odata.type", $st.SelectedItem.JsonObject."@odata.type") } if ($st.TxtDesc -and $st.TxtDesc.IsEnabled -eq $true) { $descValue = [string]$st.TxtDesc.Text if (-not $descValue -and $st.SelectedItem.Description) { $updateHT["description"] = "" } elseif ($descValue) { $updateHT["description"] = $descValue } } if ($st.ScopeTagPropName -and $null -ne $st.AssignedIds) { $assignedIds = @($st.AssignedIds) # Soft guard: Intune treats roleScopeTagIds as containing "0" # (Default) by default. A non-empty list omitting Default is # usually rejected by Graph; warn before letting the PATCH # 400 silently. Empty list isn't flagged — that's a deliberate # reset. if ($assignedIds.Count -gt 0 -and $assignedIds -notcontains "0") { $proceed = $ui.ShowMessageBox("The 'Default' scope tag (Id 0) is not in the assigned list.`n`nIntune usually requires Default to be present and the API call may fail.`n`nContinue anyway?", "Default scope tag missing", "YesNo", "Warning") if ($proceed -ne "Yes") { Write-Status ""; return } } $updateHT.Add($st.ScopeTagPropName, $assignedIds) } $updateObj = [PSCustomObject]$updateHT $api = "$($st.SelectedItem.PolicyType.API)/$($st.SelectedItem.ID)" $json = $updateObj | ConvertTo-Json -Depth 20 $ret = Invoke-MSGraphAPI $api -HttpMethod "PATCH" -Content $json -FullResponseObject -TokenId $st.SelectedItem._TokenId Write-Status "" if ($ret.Success -eq $false) { Write-Log "Failed to update object information for $($st.SelectedItem.Name). Error: $($ret.StatusCode) - $($ret.StatusDescription)" $ui.ShowMessageBox("Object information could not be verified!`n`nCheck the log file", "Update warning", "OK", "Warning") } else { Write-Log "Object information updated for $($st.SelectedItem.Name)" # Refresh in-memory object so the parent grid's ScopeTags # column reflects the new value. Clearing _ScopeTags forces # the lazy getter to re-resolve. Avalonia DataGrid has no # Items.Refresh — rebind ItemsSource to force re-pull (the # row item is also a CLR class without INPC). if ($st.ScopeTagPropName -and $null -ne $st.AssignedIds) { $assignedIds = @($st.AssignedIds) try { $st.SelectedItem.Object.($st.ScopeTagPropName) = $assignedIds } catch { } $st.SelectedItem._ScopeTags = $null $st.SelectedItem._ScopeTagsString = $null } try { # IntuneObjectRowItem carries SNAPSHOT strings copied at # build time, so re-binding alone still showed the old # name / scope tags. Update the row that wraps this # policy before the rebind. $current = $script:dgIntuneManagerObjects.ItemsSource foreach ($row in @($current)) { if (-not $row -or -not [object]::ReferenceEquals($row.Source, $st.SelectedItem)) { continue } $row.Name = [string]$st.SelectedItem.Name try { $row.ScopeTags = [string]$st.SelectedItem.ScopeTags } catch { } } $script:dgIntuneManagerObjects.ItemsSource = $null $script:dgIntuneManagerObjects.ItemsSource = $current } catch { Write-LogDebug "DataGrid refresh failed: $($_.Exception.Message)" } $st.InitialSettingsSig = Get-DetailsSettingsSignature -State $st } } catch { Write-LogError "Settings Save failed" $_.Exception; Write-Status "" } })) } # ---- Columns tab ------------------------------------------------------- $lstBasic = $hostType::FindByName($detailsForm, 'lstBasicProperties') $lstObjProps = $hostType::FindByName($detailsForm, 'lstObjectProperties') $lstObjCols = $hostType::FindByName($detailsForm, 'lstObjectColumns') $btnBasicAdd = $hostType::FindByName($detailsForm, 'btnBasicColumnsAdd') $btnObjAdd = $hostType::FindByName($detailsForm, 'btnObjectColumnsAdd') $btnColUp = $hostType::FindByName($detailsForm, 'btnObjectColumnsMoveUp') $btnColDown = $hostType::FindByName($detailsForm, 'btnObjectColumnsMoveDown') $btnColDelete = $hostType::FindByName($detailsForm, 'btnObjectColumnsDelete') $btnColClear = $hostType::FindByName($detailsForm, 'btnObjectColumnsClear') $btnColReset = $hostType::FindByName($detailsForm, 'btnObjectColumnsReset') $btnColSave = $hostType::FindByName($detailsForm, 'btnObjectColumnsSave') $grdObjCols = $hostType::FindByName($detailsForm, 'grdObjectColumns') $chkOverride = $hostType::FindByName($detailsForm, 'chkObjectColumnOverride') $lblColConfig = $hostType::FindByName($detailsForm, 'lblObjectColumnsConfig') $state.LstBasic = $lstBasic $state.LstObjProps = $lstObjProps $state.LstObjCols = $lstObjCols $state.GrdObjCols = $grdObjCols $state.ChkOverride = $chkOverride $skipBasic = @("JsonObject", "Object", "JsonString", "TenantId", "TokenId", "IsSelected") $arrBasic = foreach ($prop in ($selectedItem.PSObject.Properties | Where-Object { $skipBasic -notcontains $_.Name })) { [DetailsPropertyItem]@{ Name = $prop.Name; Value = $prop; Source = "Basic" } } # Typed cast, not a bare pipeline: Sort-Object re-wraps each item in a # PSObject, which Avalonia's binder cannot see through, so the list would # render blank rows against DisplayMemberBinding. $arrBasic = [DetailsPropertyItem[]]@($arrBasic | Sort-Object -Property Name) $skipObj = @("@odata.editLink") $arrObjProps = foreach ($prop in ($selectedItem.Object.PSObject.Properties | Where-Object { $skipObj -notcontains $_.Name -and $_.Name -notlike "*?@odata.*" })) { [DetailsPropertyItem]@{ Name = $prop.Name; Value = $prop; Source = "Object" } } $arrObjProps = [DetailsPropertyItem[]]@($arrObjProps | Sort-Object -Property Name) # Display fields come from DisplayMemberBinding in ObjectDetails.axaml. if ($lstBasic) { $lstBasic.ItemsSource = $arrBasic } if ($lstObjProps) { $lstObjProps.ItemsSource = $arrObjProps } if ($lstObjCols) { $lstObjCols.ItemsSource = $state.ColObjectProperties $lstObjCols.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_detailsTagState if (-not $st) { return } if ($st.GrdObjCols) { $st.GrdObjCols.DataContext = $st.LstObjCols.SelectedItem } })) } if ($btnBasicAdd) { $btnBasicAdd.add_Click((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_detailsTagState if (-not $st) { return } $sel = if ($st.LstBasic) { $st.LstBasic.SelectedItem } else { $null } if ($sel) { $st.ColObjectProperties.Add(([ObjectColumnInfo]::new($sel.Name, ""))) } })) } if ($btnObjAdd) { $btnObjAdd.add_Click((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_detailsTagState if (-not $st) { return } $sel = if ($st.LstObjProps) { $st.LstObjProps.SelectedItem } else { $null } if ($sel) { $st.ColObjectProperties.Add(([ObjectColumnInfo]::new("Object." + $sel.Name, ""))) } })) } if ($btnColUp) { $btnColUp.add_Click((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_detailsTagState if (-not $st) { return } if (-not $st.LstObjCols) { return } $idx = $st.LstObjCols.SelectedIndex if ($idx -gt 0) { $tmp = $st.ColObjectProperties[$idx] $st.ColObjectProperties.RemoveAt($idx) $st.ColObjectProperties.Insert(($idx - 1), $tmp) $st.LstObjCols.SelectedIndex = $idx - 1 } })) } if ($btnColDown) { $btnColDown.add_Click((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_detailsTagState if (-not $st) { return } if (-not $st.LstObjCols) { return } $idx = $st.LstObjCols.SelectedIndex if ($idx -ge 0 -and $idx -lt ($st.ColObjectProperties.Count - 1)) { $tmp = $st.ColObjectProperties[$idx] $st.ColObjectProperties.RemoveAt($idx) $st.ColObjectProperties.Insert(($idx + 1), $tmp) $st.LstObjCols.SelectedIndex = $idx + 1 } })) } if ($btnColDelete) { $btnColDelete.add_Click((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_detailsTagState if (-not $st) { return } if (-not $st.LstObjCols) { return } $idx = $st.LstObjCols.SelectedIndex if ($idx -ge 0) { if (($ui.ShowMessageBox("Are you sure you want to remove selected column?", "Remove Columns?", "YesNo", "Warning")) -ne "Yes") { return } $st.ColObjectProperties.RemoveAt($idx) } })) } if ($btnColClear) { $btnColClear.add_Click((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_detailsTagState if (-not $st) { return } if (($ui.ShowMessageBox("Are you sure you want to clear custom column settings?", "Clear Custom Columns?", "YesNo", "Warning")) -ne "Yes") { return } $st.ColObjectProperties.Clear() })) } if ($btnColReset) { $btnColReset.add_Click((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_detailsTagState if (-not $st) { return } $st.ColObjectProperties.Clear() Show-DetailsObjectDefaultColumns -State $st -Reset })) } if ($btnColSave) { $btnColSave.add_Click((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_detailsTagState if (-not $st) { return } try { $selectedType = Get-DetailsSelectedObjectTypeString if (-not $selectedType) { return } if (($ui.ShowMessageBox("Are you sure you want to save custom column settings?", "Save Custom Columns?", "YesNo", "Warning")) -ne "Yes") { return } if ($st.ColObjectProperties.Count -gt 0) { $arrCols = @() if ($st.ChkOverride -and $st.ChkOverride.IsChecked -eq $true) { $arrCols += "0" } foreach ($col in $st.ColObjectProperties) { $tmp = $col.Property if ($col.Header -and $col.Header -cne $col.Property) { $tmp = "$($tmp)=$($col.Header)" } $arrCols += $tmp } Save-SettingStoreValue "IntuneManager\ObjectColumns\$selectedType" "$($script:IntuneManagerSelectedObject.Id)" ($arrCols -join ",") } else { Remove-SettingStoreValue "IntuneManager\ObjectColumns\$selectedType" "$($script:IntuneManagerSelectedObject.Id)" } Show-DetailsObjectDefaultColumns -State $st $st.InitialColumnsSig = Get-DetailsColumnsSignature -State $st } catch { Write-LogError "Columns Save failed" $_.Exception } })) } Show-DetailsObjectDefaultColumns -State $state $state.InitialSettingsSig = Get-DetailsSettingsSignature -State $state $state.InitialColumnsSig = Get-DetailsColumnsSignature -State $state # Hook ConfirmClose so the Show-ModalForm close button checks for unsaved # changes. Mirrors the WPF impl which attached this as a ScriptMethod. # # PowerShell ScriptMethod scriptblocks invoked through Add-Member execute # outside the defining module's session state — module-scoped functions # like Test-DetailsViewHasUnsavedChanges / Show-MessageBox aren't visible # by name. Capture them as module-bound scriptblocks first (`${function:X}` # preserves the binding) and invoke via `&` from inside the closure. # # NOTE: ConvertTo-AvaloniaEventScriptBlock would STRIP these closure # captures (NewBoundScriptBlock loses GetNewClosure state), so the SB # would fail with "expression after & not valid" when invoked. The # captured ${function:X} SBs already carry their own module binding, # so we don't need ConvertTo here — plain GetNewClosure is sufficient. $sbTestDirty = ${function:Test-DetailsViewHasUnsavedChanges} $sbShowMsgBox = ${function:Show-MessageBox} $sbClearState = ${function:Clear-DetailsTagState} $detailsForm | Add-Member -MemberType ScriptMethod -Name ConfirmClose -Value ({ # Release the state on every path that actually closes - it pins the # selected policy including its full JSON after "Load full". if (-not (& $sbTestDirty -State $state)) { & $sbClearState; return $true } $result = & $sbShowMsgBox ` -Text "You have unsaved changes in Settings or Columns.`n`nClose without saving?" ` -Caption "Unsaved changes" -Button "YesNo" -Icon "Warning" if ($result -eq "Yes") { & $sbClearState; return $true } return $false }.GetNewClosure()) -Force # Per-policy-type Details buttons (Download/Edit for Script/PolicyFile, # Upload/Download for Application). Routes through Add-AvaloniaDetailsButton # to insert into pnlButtons. Failures here must not block the modal. if ($selectedItem.PolicyType.AddUIDetailsExtension) { try { $selectedItem.PolicyType.AddUIDetailsExtension($detailsForm) } catch { Write-LogError "AddUIDetailsExtension failed for $($selectedItem.PolicyType.Name)" $_.Exception } } $ui.ShowModalForm($FormTitle, $detailsForm) } # --------------------------------------------------------------------------- # Helpers (private to this file). State is passed in explicitly rather than # pulled from $script: vars so a future "open two details dialogs at once" # flow doesn't require restructuring. # --------------------------------------------------------------------------- function Get-DetailsSelectedObjectTypeString { if ($script:IntuneManagerSelectedObject -is [IntunePolicyGroupBase]) { return "PolicyGroup" } if ($script:IntuneManagerSelectedObject -is [IntunePolicyTypeBase]) { return "PolicyType" } return "" } function Show-DetailsObjectDefaultColumns { param( [Parameter(Mandatory)] $State, [switch]$Reset ) $hostType = Get-AvaloniaHost $lblColConfig = $hostType::FindByName($State.Form, 'lblObjectColumnsConfig') $chkOverride = $hostType::FindByName($State.Form, 'chkObjectColumnOverride') if ($Reset -ne $true) { $selectedType = Get-DetailsSelectedObjectTypeString if (-not $selectedType) { return } $strColSettings = Get-SettingStoreValue "IntuneManager\ObjectColumns\$selectedType" "$($script:IntuneManagerSelectedObject.Id)" } else { $strColSettings = "" } $State.ColObjectProperties.Clear() $defaultColumns = $script:IntuneManagerSelectedObject.ViewProperties if ($strColSettings) { $arrColSettings = $strColSettings -split ",|;" if ($chkOverride) { $chkOverride.IsChecked = ($arrColSettings.Count -gt 0 -and $arrColSettings[0] -eq "0") } $start = 0 if ($arrColSettings.Count -gt 0 -and ($arrColSettings[0] -eq "0" -or $arrColSettings[0] -eq "1")) { $start = 1 } $colArr = @() for ($i = $start; $i -lt $arrColSettings.Count; $i++) { $colProp, $colHeader = $arrColSettings[$i].Split("=") if (-not $colHeader) { $colHeader = $colProp } $State.ColObjectProperties.Add([ObjectColumnInfo]::new($colProp, $colHeader)) $colArr += $colProp } if ($arrColSettings.Count -eq 0 -or $arrColSettings[0] -ne "0") { $colArr = @($defaultColumns) + $colArr } if ($lblColConfig) { $lblColConfig.Text = ($colArr -join ',') } } else { if ($lblColConfig) { $lblColConfig.Text = "$($defaultColumns -join ',') (Default)" } } } function Get-DetailsSettingsSignature { param([Parameter(Mandatory)] $State) if (-not $State.Form) { return "" } $hostType = Get-AvaloniaHost $txtName = $hostType::FindByName($State.Form, 'txtObjectName') $txtDesc = $hostType::FindByName($State.Form, 'txtObjectDescription') $scopeTagIds = @() if ($State.ScopeTagPropName -and $null -ne $State.AssignedIds) { $scopeTagIds = @($State.AssignedIds | Sort-Object) } $descriptionEnabled = $false if ($txtDesc) { $descriptionEnabled = [bool]$txtDesc.IsEnabled } $signature = [PSCustomObject]@{ Name = if ($txtName) { [string]$txtName.Text } else { "" } DescriptionEnabled = $descriptionEnabled Description = if ($descriptionEnabled -and $txtDesc) { [string]$txtDesc.Text } else { $null } ScopeTagProperty = [string]$State.ScopeTagPropName ScopeTagIds = $scopeTagIds } return ($signature | ConvertTo-Json -Depth 10 -Compress) } function Get-DetailsColumnsSignature { param([Parameter(Mandatory)] $State) if (-not $State.Form) { return "" } $hostType = Get-AvaloniaHost $chkOverride = $hostType::FindByName($State.Form, 'chkObjectColumnOverride') $columns = @() foreach ($col in @($State.ColObjectProperties)) { if (-not $col) { continue } $columns += [PSCustomObject]@{ Property = [string]$col.Property; Header = [string]$col.Header } } $signature = [PSCustomObject]@{ OverrideDefaultColumns = if ($chkOverride) { [bool]$chkOverride.IsChecked } else { $false } Columns = $columns } return ($signature | ConvertTo-Json -Depth 20 -Compress) } function Test-DetailsViewHasUnsavedChanges { param([Parameter(Mandatory)] $State) if (-not $State.Form) { return $false } $settingsDirty = $false $columnsDirty = $false try { if ($State.InitialSettingsSig) { $settingsDirty = ((Get-DetailsSettingsSignature -State $State) -ne $State.InitialSettingsSig) } } catch { Write-LogDebug "Details settings dirty check failed: $($_.Exception.Message)" } try { if ($State.InitialColumnsSig) { $columnsDirty = ((Get-DetailsColumnsSignature -State $State) -ne $State.InitialColumnsSig) } } catch { Write-LogDebug "Details columns dirty check failed: $($_.Exception.Message)" } return ($settingsDirty -or $columnsDirty) } function Clear-DetailsTagState { # Called from the ConfirmClose ScriptMethod, which runs outside the module's # session state - a bare $script: assignment there would target the caller's # scope, not the module's. Capture this as ${function:Clear-DetailsTagState} # and invoke it with & so the write lands in module scope. $script:_detailsTagState = $null }