private/Controls/New-UiDataGridTextColumn.ps1
|
function New-UiDataGridTextColumn { <# .SYNOPSIS Builds a DataGrid column. Probes row values to pick Text/CheckBox/ComboBox/DatePicker when editable. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Name, [Parameter(Mandatory)] [object]$FirstItem, [hashtable]$Column, [switch]$Editable, # Extra rows Build-UiDataGridColumns hands in so a column whose first row is null doesn't fall back to readonly Text just because row 0 happens to be empty. [object[]]$SampleItems ) $header = if ($Column -and $Column.Header) { [string]$Column.Header } else { $Name } # ReadOnly = $true wins outright. Otherwise per column Editable (bool, scriptblock, or property name) overrides the grid wide -Editable. Anything nonboolean will be editable here. The actual percell check runs later. $perColRO = $Column -and $Column.ContainsKey('ReadOnly') -and $Column['ReadOnly'] $isEditable = [bool]$Editable if ($Column -and $Column.ContainsKey('Editable')) { $editVal = $Column['Editable'] if ($editVal -is [bool]) { $isEditable = $editVal } elseif ($editVal -is [scriptblock] -or $editVal -is [string]) { $isEditable = $true } } if ($perColRO) { $isEditable = $false } $member = $FirstItem.PSObject.Properties[$Name] $baseItem = $FirstItem.PSObject.BaseObject if ($member -and !$member.IsSettable) { $isEditable = $false } if ($isEditable -and $member -and $baseItem -isnot [System.Management.Automation.PSCustomObject]) { $lookupName = if ($member -is [System.Management.Automation.PSAliasProperty]) { $member.ReferencedMemberName } else { $Name } $descriptor = [System.ComponentModel.TypeDescriptor]::GetProperties($baseItem).Find($lookupName, $true) if (($FirstItem -isnot [psobject] -and !$descriptor) -or ($descriptor -and $descriptor.IsReadOnly)) { $isEditable = $false } } $editorType = if ($Column -and $Column.EditorType) { [string]$Column.EditorType } else { 'Auto' } # Probe rows to pick an editor type. Only runs when EditorType='Auto'. First non null wins, so an enum/datetime column with a null in row 0 still resolves correctly. Cap at 10 rows so a 100k row seed doesn't pay a column build cost proportional to the data size. $valueType = $null $probeSet = @(if ($SampleItems) { $SampleItems } else { $FirstItem }) $probeMax = [Math]::Min(10, $probeSet.Count) for ($i = 0; $i -lt $probeMax; $i++) { $row = $probeSet[$i] if ($null -eq $row) { continue } try { $prop = $row.PSObject.Properties[$Name] if ($null -eq $prop) { continue } $sampleVal = $prop.Value if ($null -ne $sampleVal) { $valueType = $sampleVal.GetType(); break } } catch { Write-Debug "Type probe failed for '$Name' at row $i`: $_" } } $resolved = $editorType if ($isEditable -and $editorType -eq 'Auto') { if ($valueType -eq [bool]) { $resolved = 'CheckBox' } elseif ($valueType -and $valueType.IsEnum) { $resolved = 'ComboBox' } elseif ($valueType -eq [datetime]) { $resolved = 'DatePicker' } elseif ($valueType -eq [string] -or $valueType -eq [decimal] -or ($valueType -and $valueType.IsPrimitive)) { $resolved = 'Text' } else { # Complex object, null first row, or just an unsupported type? downgrade to readonly. # Pass EditorType='Text' on the column hashtable to override and force an editable text cell. Write-Debug "Column '$Name' has unsupported edit type ($valueType); forcing read-only text." $resolved = 'Text' $isEditable = $false } } # Alias columns (Add-Member AliasProperty rows) display through the referenced member, but a SortMemberPath pointing at the alias name matches nothing and the sort silently dies. Resolve once here so every branch binds and sorts the real path. $bindPath = $Name try { $prop = $FirstItem.PSObject.Properties[$Name] if ($prop -is [System.Management.Automation.PSAliasProperty]) { $target = $FirstItem.PSObject.Properties[$prop.ReferencedMemberName] $bindPath = if ($target) { $target.Name } else { $prop.ReferencedMemberName } } # @{ Name = 'displayname' } binds blank on a plain row otherwise elseif ($prop) { $bindPath = $prop.Name } } catch { Write-Debug "Alias check failed for '$Name': $_" } switch ($resolved) { 'CheckBox' { $col = [System.Windows.Controls.DataGridCheckBoxColumn]::new() $col.Header = $header $col.SortMemberPath = $bindPath $binding = [System.Windows.Data.Binding]::new($bindPath) $binding.Mode = if ($isEditable) { [System.Windows.Data.BindingMode]::TwoWay } else { [System.Windows.Data.BindingMode]::OneWay } # $null in a bool column renders the indeterminate dash, which reads as a tristate. # TargetNullValue collapses null to false at the binding. IsThreeState=$false stops the click cycle from reintroducing it. $binding.TargetNullValue = $false $col.Binding = $binding $col.IsReadOnly = !$isEditable $col.IsThreeState = $false # Default DataGridCheckBoxColumn renders the raw OS checkbox - looks straight out of 2003 next to themed controls. Reskin with the same template Set-CheckBoxStyle uses. $cbStyle = Get-UiDataGridCheckBoxStyle $col.ElementStyle = $cbStyle $col.EditingElementStyle = $cbStyle } 'ComboBox' { $col = [System.Windows.Controls.DataGridComboBoxColumn]::new() $col.Header = $header $col.SortMemberPath = $bindPath $col.IsReadOnly = !$isEditable $choices = if ($Column -and $Column.Choices) { @($Column.Choices) } elseif ($valueType -and $valueType.IsEnum) { [enum]::GetValues($valueType) } else { @() } # String Choices against an enum prop never match SelectedValue, so every non editing cell renders blank. Parse them into the enum, keeping the user's subset instead of falling back to GetValues. if ($valueType -and $valueType.IsEnum -and $choices.Count -gt 0 -and $choices[0] -is [string]) { try { $choices = @($choices | ForEach-Object { [enum]::Parse($valueType, $_, $true) }) } catch { Write-Debug "Column '$Name': Choices don't parse as $($valueType.Name); leaving them as strings." } } $col.ItemsSource = $choices # SelectedValueBinding (not SelectedItemBinding) so a string Choices array against a typed property doesn't write a string back. For enums the default SelectedValuePath is fine. ComboBox handles the value coercion. $sel = [System.Windows.Data.Binding]::new($bindPath) $sel.Mode = if ($isEditable) { [System.Windows.Data.BindingMode]::TwoWay } else { [System.Windows.Data.BindingMode]::OneWay } $sel.UpdateSourceTrigger = [System.Windows.Data.UpdateSourceTrigger]::PropertyChanged $col.SelectedValueBinding = $sel # Enums are self coercing through SelectedValuePath. Flat string Choices against a typed prop write a string back unless you pass @{Value=;Display=} hashtables and set the paths. if ($Column -and $Column.Choices -and !($valueType -and $valueType.IsEnum)) { Write-Debug "Column '$Name': flat Choices array. Pass @{Value=...; Display=...} hashtables + SelectedValuePath/SelectedDisplayMemberPath if the prop is typed." } if ($isEditable) { $col.EditingElementStyle = Get-UiDataGridTextEditorStyle -EditorKind ComboBox } } 'DatePicker' { $col = [System.Windows.Controls.DataGridTemplateColumn]::new() $col.Header = $header $col.SortMemberPath = $bindPath $displayTpl = [System.Windows.DataTemplate]::new() $displayFactory = [System.Windows.FrameworkElementFactory]::new([System.Windows.Controls.TextBlock]) $displayBinding = [System.Windows.Data.Binding]::new($bindPath) $displayBinding.Mode = [System.Windows.Data.BindingMode]::OneWay if ($Column -and $Column.Format) { $displayBinding.StringFormat = [string]$Column.Format } $displayFactory.SetBinding([System.Windows.Controls.TextBlock]::TextProperty, $displayBinding) $displayFactory.SetValue([System.Windows.Controls.TextBlock]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center) $displayTpl.VisualTree = $displayFactory $col.CellTemplate = $displayTpl if ($isEditable) { $editTpl = [System.Windows.DataTemplate]::new() $editFactory = [System.Windows.FrameworkElementFactory]::new([System.Windows.Controls.DatePicker]) $editBinding = [System.Windows.Data.Binding]::new($bindPath) $editBinding.Mode = [System.Windows.Data.BindingMode]::TwoWay $editBinding.UpdateSourceTrigger = [System.Windows.Data.UpdateSourceTrigger]::LostFocus $editFactory.SetBinding([System.Windows.Controls.DatePicker]::SelectedDateProperty, $editBinding) $dpStyle = Get-UiDataGridTextEditorStyle -EditorKind DatePicker $editFactory.SetValue([System.Windows.Controls.DatePicker]::StyleProperty, $dpStyle) $editTpl.VisualTree = $editFactory $col.CellEditingTemplate = $editTpl } $col.IsReadOnly = !$isEditable } default { $col = [System.Windows.Controls.DataGridTextColumn]::new() $col.Header = $header $col.SortMemberPath = $bindPath if ($bindPath -match '[.\[]') { Write-Warning "Column '$Name': property name contains '.' or '[' which WPF Binding interprets as a path. The column will render blank. Rename the property or pre-flatten it." } # Plain binding. The upstream snapshot in New-UiDataGrid flattens objects into PSCustomObjects, so PowerShell added properties show up like regular ones, no per cell cost. $binding = [System.Windows.Data.Binding]::new($bindPath) $binding.Mode = if ($isEditable) { [System.Windows.Data.BindingMode]::TwoWay } else { [System.Windows.Data.BindingMode]::OneWay } if ($isEditable) { $binding.UpdateSourceTrigger = [System.Windows.Data.UpdateSourceTrigger]::LostFocus } else { $binding.Converter = [PsUi.SingleLineConverter]::new() } if ($Column -and $Column.Format) { $binding.StringFormat = [string]$Column.Format } $col.Binding = $binding $col.IsReadOnly = !$isEditable if ($isEditable) { $col.EditingElementStyle = Get-UiDataGridTextEditorStyle } } } $col.Width = if ($Column -and $Column.Width) { ConvertTo-UiDataGridLength $Column.Width } else { [System.Windows.Controls.DataGridLength]::Auto } # MinWidth floor. Keeps the header text from truncating into ellipses AND keeps editable cells large enough to land a click on. Matches Add-DataGridColumns. $Column.MinWidth overrides if you want something else. $headerMin = [Math]::Max(60, ($header.Length * 7) + 30) $col.MinWidth = if ($Column -and $Column.MinWidth) { [double]$Column.MinWidth } else { $headerMin } return $col } |