diff --git a/src/Avalonia.Controls.DataGrid.LeakTests/LeakTests.cs b/src/Avalonia.Controls.DataGrid.LeakTests/LeakTests.cs index ee0df05e..9b0bffa1 100644 --- a/src/Avalonia.Controls.DataGrid.LeakTests/LeakTests.cs +++ b/src/Avalonia.Controls.DataGrid.LeakTests/LeakTests.cs @@ -1015,6 +1015,24 @@ WeakReference Run() GC.KeepAlive(items); } + [ReleaseFact] + public void DataGrid_RemovedNotifyDataErrorInfoCell_DoesNotLeak() + { + var items = new ObservableCollection + { + new NotifyDataErrorRowItem("A") + }; + items[0].SetErrors(new[] { "Invalid" }); + + var (grid, cellRef) = RunInSession(() => + RunRemovedNotifyDataErrorInfoCell(items)); + + AssertCollected(cellRef); + + GC.KeepAlive(grid); + GC.KeepAlive(items); + } + [ReleaseFact] public void DataGrid_NotifyDataErrorInfo_ErrorsChanged_DoesNotLeak() { @@ -1817,6 +1835,52 @@ private static WeakReference RunColumnDefinitionsSourceSwap( return gridRef; } + [MethodImpl(MethodImplOptions.NoInlining)] + private static (DataGrid Grid, WeakReference CellRef) RunRemovedNotifyDataErrorInfoCell( + ObservableCollection items) + { + var grid = new DataGrid + { + AutoGenerateColumns = false, + ItemsSource = items + }; + grid.Columns.Add(new DataGridTextColumn + { + Header = "Name", + Binding = new Binding(nameof(NotifyDataErrorRowItem.Name)) + }); + + var window = new Window + { + Width = 600, + Height = 300, + Content = grid + }; + window.SetThemeStyles(); + ShowWindow(window); + grid.ScrollIntoView(items[0], grid.Columns[0]); + Dispatcher.UIThread.RunJobs(); + PumpLayout(grid); + + grid.SelectedItem = items[0]; + grid.CurrentCell = new DataGridCellInfo(items[0], grid.Columns[0], 0, 0); + Dispatcher.UIThread.RunJobs(); + Assert.True(grid.BeginEdit()); + Dispatcher.UIThread.RunJobs(); + var row = Assert.IsType(grid.EditingRow); + DataGridCell cell = row.Cells[0]; + Assert.True(grid.CancelEdit(DataGridEditingUnit.Cell)); + Dispatcher.UIThread.RunJobs(); + Assert.True(DataValidationErrors.GetHasErrors(cell)); + var cellRef = new WeakReference(cell); + + grid.Columns.RemoveAt(0); + Dispatcher.UIThread.RunJobs(); + CleanupWindow(window); + + return (grid, cellRef); + } + [MethodImpl(MethodImplOptions.NoInlining)] private static ( WeakReference GridRef, diff --git a/src/Avalonia.Controls.DataGrid.UnitTests/Validation/DataGridValidationTests.cs b/src/Avalonia.Controls.DataGrid.UnitTests/Validation/DataGridValidationTests.cs index 868cd81b..6fe41119 100644 --- a/src/Avalonia.Controls.DataGrid.UnitTests/Validation/DataGridValidationTests.cs +++ b/src/Avalonia.Controls.DataGrid.UnitTests/Validation/DataGridValidationTests.cs @@ -12,6 +12,7 @@ using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; +using Avalonia.Controls.Utils; using Avalonia.Data; using Avalonia.Data.Core.Plugins; using Avalonia.Headless.XUnit; @@ -412,6 +413,75 @@ public void INotifyDataErrorInfo_edit_clears_cell_errors_while_editing() } } + [AvaloniaFact] + public void INotifyDataErrorInfo_committed_warning_is_cleared_by_later_model_change() + { + var (grid, root, item, column) = CreateNotifyValidationGrid(); + + try + { + int slot = grid.SlotFromRowIndex(0); + Assert.True(grid.UpdateSelectionAndCurrency( + column.Index, + slot, + DataGridSelectionAction.SelectCurrent, + scrollIntoView: false)); + Assert.True(grid.BeginEdit()); + grid.UpdateLayout(); + + Assert.True(grid.CommitEdit(DataGridEditingUnit.Cell, exitEditingMode: true)); + DataGridCell cell = FindCell(grid, item, column.Index); + Assert.Equal(DataGridValidationSeverity.Warning, cell.ValidationSeverity); + Assert.True(DataValidationErrors.GetHasErrors(cell)); + + item.Code = "Valid"; + grid.UpdateLayout(); + + Assert.Equal(DataGridValidationSeverity.None, cell.ValidationSeverity); + Assert.False(DataValidationErrors.GetHasErrors(cell)); + } + finally + { + root.Close(); + } + } + + [AvaloniaFact] + public void INotifyDataErrorInfo_warning_introduced_by_edit_is_later_cleared() + { + var (grid, root, item, column) = CreateNotifyValidationGrid(initialCode: "Valid"); + + try + { + int slot = grid.SlotFromRowIndex(0); + Assert.True(grid.UpdateSelectionAndCurrency( + column.Index, + slot, + DataGridSelectionAction.SelectCurrent, + scrollIntoView: false)); + Assert.True(grid.BeginEdit()); + grid.UpdateLayout(); + + DataGridCell cell = FindCell(grid, item, column.Index); + TextBox textBox = Assert.IsType(cell.Content); + textBox.Text = "X"; + UpdateEditingElementSource(textBox); + Assert.True(grid.CommitEdit(DataGridEditingUnit.Cell, exitEditingMode: true)); + Assert.Equal(DataGridValidationSeverity.Warning, cell.ValidationSeverity); + Assert.True(DataValidationErrors.GetHasErrors(cell)); + + item.Code = "Valid"; + grid.UpdateLayout(); + + Assert.Equal(DataGridValidationSeverity.None, cell.ValidationSeverity); + Assert.False(DataValidationErrors.GetHasErrors(cell)); + } + finally + { + root.Close(); + } + } + [AvaloniaFact] public void INotifyDataErrorInfo_validation_restores_on_row_recycle() { @@ -447,6 +517,408 @@ public void INotifyDataErrorInfo_validation_restores_on_row_recycle() } } + [AvaloniaFact] + public void INotifyDataErrorInfo_programmatic_change_refreshes_realized_cell_validation() + { + var (grid, root, item, column) = CreateNotifyValidationGrid(initialCode: "Valid"); + + try + { + var cell = FindCell(grid, item, column.Index); + Assert.Equal(DataGridValidationSeverity.None, cell.ValidationSeverity); + Assert.False(DataValidationErrors.GetHasErrors(cell)); + + item.Code = "X"; + grid.UpdateLayout(); + + Assert.Equal(DataGridValidationSeverity.Warning, cell.ValidationSeverity); + Assert.True(((IPseudoClasses)cell.Classes).Contains(":warning")); + Assert.True(DataValidationErrors.GetHasErrors(cell)); + + item.Code = "Valid"; + grid.UpdateLayout(); + + Assert.Equal(DataGridValidationSeverity.None, cell.ValidationSeverity); + Assert.False(((IPseudoClasses)cell.Classes).Contains(":warning")); + Assert.False(DataValidationErrors.GetHasErrors(cell)); + } + finally + { + root.Close(); + } + } + + [AvaloniaFact] + public void INotifyDataErrorInfo_refresh_preserves_same_cell_binding_error() + { + var (grid, root, item, column) = CreateNotifyValidationGrid(initialCode: "Valid"); + + try + { + DataGridCell cell = FindCell(grid, item, column.Index); + var bindingError = new DataValidationException( + new DataGridValidationResult("Binding error", DataGridValidationSeverity.Error)); + DataValidationErrors.SetError(cell, bindingError); + object displayedBindingError = Assert.Single(DataValidationErrors.GetErrors(cell)); + cell.IsValid = false; + cell.ValidationSeverity = DataGridValidationSeverity.Error; + cell.UpdatePseudoClasses(); + + item.Code = "X"; + grid.UpdateLayout(); + + Assert.Contains(displayedBindingError, DataValidationErrors.GetErrors(cell)); + Assert.True(DataValidationErrors.GetHasErrors(cell)); + + item.Code = "Valid"; + grid.UpdateLayout(); + + Assert.Same(displayedBindingError, Assert.Single(DataValidationErrors.GetErrors(cell))); + Assert.Equal(DataGridValidationSeverity.Error, cell.ValidationSeverity); + Assert.False(cell.IsValid); + } + finally + { + root.Close(); + } + } + + [AvaloniaFact] + public void INotifyDataErrorInfo_all_property_change_preserves_binding_validation() + { + var (grid, root, item, _, warningColumn) = CreateMixedValidationGrid(); + + try + { + item.ErrorValue = "Valid"; + grid.UpdateLayout(); + int slot = grid.SlotFromRowIndex(0); + Assert.True(grid.UpdateSelectionAndCurrency( + warningColumn.Index, + slot, + DataGridSelectionAction.SelectCurrent, + scrollIntoView: false)); + grid.UpdateLayout(); + + Assert.True(grid.BeginEdit()); + grid.UpdateLayout(); + + DataGridCell warningCell = FindCell(grid, item, warningColumn.Index); + TextBox textBox = Assert.IsType(warningCell.Content); + textBox.Text = "X"; + UpdateEditingElementSource(textBox); + Assert.True(grid.CommitEdit(DataGridEditingUnit.Cell, exitEditingMode: true)); + Assert.True(grid.CommitEdit(DataGridEditingUnit.Row, exitEditingMode: true)); + grid.UpdateLayout(); + + Assert.Equal(DataGridValidationSeverity.Warning, warningCell.ValidationSeverity); + Assert.True(DataValidationErrors.GetHasErrors(warningCell)); + + item.NotifyAllPropertiesChanged(); + grid.UpdateLayout(); + + Assert.Equal(DataGridValidationSeverity.Warning, warningCell.ValidationSeverity); + Assert.True(DataValidationErrors.GetHasErrors(warningCell)); + } + finally + { + root.Close(); + } + } + + [AvaloniaFact] + public void INotifyDataErrorInfo_all_property_change_clears_removed_indei_error() + { + var (grid, root, item, errorColumn, _) = CreateMixedValidationGrid(); + + try + { + DataGridCell errorCell = FindCell(grid, item, errorColumn.Index); + Assert.Equal(DataGridValidationSeverity.Error, errorCell.ValidationSeverity); + + item.ClearErrorsAndNotifyAllProperties(); + grid.UpdateLayout(); + + Assert.Equal(DataGridValidationSeverity.None, errorCell.ValidationSeverity); + Assert.False(DataValidationErrors.GetHasErrors(errorCell)); + } + finally + { + root.Close(); + } + } + + [AvaloniaFact] + public void INotifyDataErrorInfo_clear_preserves_same_cell_setter_warning_after_edit() + { + var item = new MixedValidationItem + { + WarningValue = "Okay" + }; + item.SetWarningValueError(); + var root = new Window + { + Width = 600, + Height = 300 + }; + root.SetThemeStyles(DataGridTheme.Simple); + var grid = new DataGrid + { + ItemsSource = new ObservableCollection { item }, + AutoGenerateColumns = false + }; + var warningColumn = new MixedSourceValidationColumn(); + grid.ColumnsInternal.Add(warningColumn); + root.Content = grid; + root.Show(); + grid.ApplyTemplate(); + grid.UpdateLayout(); + + try + { + int slot = grid.SlotFromRowIndex(0); + Assert.True(grid.UpdateSelectionAndCurrency( + warningColumn.Index, + slot, + DataGridSelectionAction.SelectCurrent, + scrollIntoView: false)); + Assert.True(grid.BeginEdit()); + grid.UpdateLayout(); + + DataGridCell warningCell = FindCell(grid, item, warningColumn.Index); + Assert.True(grid.CommitEdit(DataGridEditingUnit.Cell, exitEditingMode: true)); + grid.UpdateLayout(); + + Assert.Contains( + DataValidationErrors.GetErrors(warningCell), + error => ErrorContainsMessage(error, "Model warning")); + Assert.Contains( + DataValidationErrors.GetErrors(warningCell), + error => ErrorContainsMessage(error, "Warning value should")); + + item.ClearWarningValueError(); + grid.UpdateLayout(); + + Assert.DoesNotContain( + DataValidationErrors.GetErrors(warningCell), + error => ErrorContainsMessage(error, "Model warning")); + Assert.Contains( + DataValidationErrors.GetErrors(warningCell), + error => ErrorContainsMessage(error, "Warning value should")); + Assert.Equal(DataGridValidationSeverity.Warning, warningCell.ValidationSeverity); + } + finally + { + root.Close(); + } + } + + [AvaloniaFact] + public void INotifyDataErrorInfo_clear_preserves_identical_setter_warning_after_edit() + { + var item = new SameMessageValidationItem(); + var root = new Window + { + Width = 600, + Height = 300 + }; + root.SetThemeStyles(DataGridTheme.Simple); + var grid = new DataGrid + { + ItemsSource = new ObservableCollection { item }, + AutoGenerateColumns = false + }; + var column = new DataGridTextColumn + { + Header = "Value", + Binding = new Binding(nameof(SameMessageValidationItem.Value)) + }; + grid.ColumnsInternal.Add(column); + root.Content = grid; + root.Show(); + grid.ApplyTemplate(); + grid.UpdateLayout(); + + try + { + int slot = grid.SlotFromRowIndex(0); + Assert.True(grid.UpdateSelectionAndCurrency( + column.Index, + slot, + DataGridSelectionAction.SelectCurrent, + scrollIntoView: false)); + Assert.True(grid.BeginEdit()); + grid.UpdateLayout(); + + DataGridCell cell = FindCell(grid, item, column.Index); + TextBox textBox = Assert.IsType(cell.Content); + textBox.Text = "Rejected"; + Assert.True(grid.CommitEdit(DataGridEditingUnit.Cell, exitEditingMode: true)); + grid.UpdateLayout(); + + Assert.Equal( + 2, + DataValidationErrors.GetErrors(cell).Count( + error => ErrorContainsMessage(error, SameMessageValidationItem.ErrorMessage))); + + item.ClearModelError(); + grid.UpdateLayout(); + + Assert.Single( + DataValidationErrors.GetErrors(cell), + error => ErrorContainsMessage(error, SameMessageValidationItem.ErrorMessage)); + Assert.Equal(DataGridValidationSeverity.Warning, cell.ValidationSeverity); + Assert.True(cell.IsValid); + } + finally + { + root.Close(); + } + } + + [AvaloniaFact] + public void INotifyDataErrorInfo_clear_preserves_identical_custom_binding_warning_after_edit() + { + var item = new SameMessageValidationItem(); + var root = new Window + { + Width = 600, + Height = 300 + }; + root.SetThemeStyles(DataGridTheme.Simple); + var grid = new DataGrid + { + ItemsSource = new ObservableCollection { item }, + AutoGenerateColumns = false + }; + var column = new SameMessageCustomValidationColumn(); + grid.ColumnsInternal.Add(column); + root.Content = grid; + root.Show(); + grid.ApplyTemplate(); + grid.UpdateLayout(); + + try + { + int slot = grid.SlotFromRowIndex(0); + Assert.True(grid.UpdateSelectionAndCurrency( + column.Index, + slot, + DataGridSelectionAction.SelectCurrent, + scrollIntoView: false)); + Assert.True(grid.BeginEdit()); + grid.UpdateLayout(); + + DataGridCell cell = FindCell(grid, item, column.Index); + Assert.True(grid.CommitEdit(DataGridEditingUnit.Cell, exitEditingMode: true)); + grid.UpdateLayout(); + + Assert.Equal( + 2, + DataValidationErrors.GetErrors(cell).Count( + error => ErrorContainsMessage(error, SameMessageValidationItem.ErrorMessage))); + + item.ClearModelError(); + grid.UpdateLayout(); + + Assert.Single( + DataValidationErrors.GetErrors(cell), + error => ErrorContainsMessage(error, SameMessageValidationItem.ErrorMessage)); + Assert.Equal(DataGridValidationSeverity.Warning, cell.ValidationSeverity); + Assert.True(cell.IsValid); + } + finally + { + root.Close(); + } + } + + [AvaloniaFact] + public void INotifyDataErrorInfo_error_overrides_custom_binding_warning_during_edit() + { + var item = new SameMessageValidationItem(DataGridValidationSeverity.Error); + var root = new Window + { + Width = 600, + Height = 300 + }; + root.SetThemeStyles(DataGridTheme.Simple); + var grid = new DataGrid + { + ItemsSource = new ObservableCollection { item }, + AutoGenerateColumns = false + }; + var column = new SameMessageCustomValidationColumn(); + grid.ColumnsInternal.Add(column); + root.Content = grid; + root.Show(); + grid.ApplyTemplate(); + grid.UpdateLayout(); + + try + { + int slot = grid.SlotFromRowIndex(0); + Assert.True(grid.UpdateSelectionAndCurrency( + column.Index, + slot, + DataGridSelectionAction.SelectCurrent, + scrollIntoView: false)); + Assert.True(grid.BeginEdit()); + grid.UpdateLayout(); + + DataGridCell cell = FindCell(grid, item, column.Index); + DataGridRow row = FindRow(grid, item); + Assert.False(grid.CommitEdit()); + grid.UpdateLayout(); + + Assert.Equal(DataGridValidationSeverity.Error, cell.ValidationSeverity); + Assert.False(cell.IsValid); + Assert.Equal(DataGridValidationSeverity.Error, row.ValidationSeverity); + Assert.False(row.IsValid); + Assert.False(grid.IsValid); + Assert.Equal( + 2, + DataValidationErrors.GetErrors(cell).Count( + error => ErrorContainsMessage(error, SameMessageValidationItem.ErrorMessage))); + } + finally + { + root.Close(); + } + } + + [AvaloniaFact] + public void INotifyDataErrorInfo_change_refreshes_non_editing_cell_on_editing_row() + { + var (grid, root, item, errorColumn, warningColumn) = CreateMixedValidationGrid(); + + try + { + int slot = grid.SlotFromRowIndex(0); + Assert.True(grid.UpdateSelectionAndCurrency( + warningColumn.Index, + slot, + DataGridSelectionAction.SelectCurrent, + scrollIntoView: false)); + Assert.True(grid.BeginEdit()); + grid.UpdateLayout(); + + DataGridCell errorCell = FindCell(grid, item, errorColumn.Index); + Assert.Equal(DataGridValidationSeverity.Error, errorCell.ValidationSeverity); + + item.ErrorValue = "Valid"; + grid.UpdateLayout(); + + Assert.Equal(DataGridValidationSeverity.None, errorCell.ValidationSeverity); + Assert.False(DataValidationErrors.GetHasErrors(errorCell)); + Assert.Equal(warningColumn.Index, grid.EditingColumnIndex); + } + finally + { + root.Close(); + } + } + [AvaloniaFact] public void Grid_invalid_when_offscreen_item_has_error() { @@ -830,14 +1302,19 @@ private static (DataGrid grid, Window root, InfoValidationItem item, DataGridTex private static (DataGrid grid, Window root, NotifyValidationItem item, DataGridTextColumn column) CreateNotifyValidationGrid() { - return CreateNotifyValidationGrid(TwoWayBinding(nameof(NotifyValidationItem.Code))); + return CreateNotifyValidationGrid(TwoWayBinding(nameof(NotifyValidationItem.Code)), "X"); + } + + private static (DataGrid grid, Window root, NotifyValidationItem item, DataGridTextColumn column) CreateNotifyValidationGrid(string initialCode) + { + return CreateNotifyValidationGrid(TwoWayBinding(nameof(NotifyValidationItem.Code)), initialCode); } - private static (DataGrid grid, Window root, NotifyValidationItem item, DataGridTextColumn column) CreateNotifyValidationGrid(BindingBase binding) + private static (DataGrid grid, Window root, NotifyValidationItem item, DataGridTextColumn column) CreateNotifyValidationGrid(BindingBase binding, string initialCode = "X") { var item = new NotifyValidationItem { - Code = "X" + Code = initialCode }; var items = new ObservableCollection { item }; @@ -1809,6 +2286,29 @@ public IEnumerable GetErrors(string? propertyName) return Array.Empty(); } + public void ClearErrorsAndNotifyAllProperties() + { + _errors.Clear(); + ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(null)); + } + + public void NotifyAllPropertiesChanged() + { + ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(null)); + } + + public void SetWarningValueError() + { + SetError( + nameof(WarningValue), + new DataGridValidationResult("Model warning.", DataGridValidationSeverity.Warning)); + } + + public void ClearWarningValueError() + { + SetError(nameof(WarningValue), null); + } + private void SetError(string propertyName, DataGridValidationResult? error) { if (error == null) @@ -1846,6 +2346,175 @@ private bool SetProperty(ref T field, T value, [CallerMemberName] string? pro } } + private sealed class SameMessageValidationItem : INotifyPropertyChanged, INotifyDataErrorInfo + { + public const string ErrorMessage = "Value is rejected."; + + private readonly DataGridValidationSeverity _modelSeverity; + private string _value = "Initial"; + private bool _hasModelError = true; + + public SameMessageValidationItem( + DataGridValidationSeverity modelSeverity = DataGridValidationSeverity.Warning) + { + _modelSeverity = modelSeverity; + } + + public event PropertyChangedEventHandler? PropertyChanged; + public event EventHandler? ErrorsChanged; + + public string Value + { + get => _value; + set + { + if (string.Equals(value, "Rejected", StringComparison.Ordinal)) + { + throw new DataValidationException( + new DataGridValidationResult(ErrorMessage, DataGridValidationSeverity.Warning)); + } + + if (!string.Equals(_value, value, StringComparison.Ordinal)) + { + _value = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value))); + } + } + } + + public bool HasErrors => _hasModelError; + + public IEnumerable GetErrors(string? propertyName) + { + if (_hasModelError && + (string.IsNullOrEmpty(propertyName) || propertyName == nameof(Value))) + { + return new[] + { + new DataGridValidationResult(ErrorMessage, _modelSeverity) + }; + } + + return Array.Empty(); + } + + public void ClearModelError() + { + _hasModelError = false; + ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value))); + } + } + + private sealed class SameMessageCustomValidationColumn : DataGridComboBoxColumn + { + public SameMessageCustomValidationColumn() + { + Header = "Value"; + SelectedValueBinding = TwoWayBinding(nameof(SameMessageValidationItem.Value)); + } + + protected override Control GenerateEditingElement( + DataGridCell cell, + object dataItem, + out ICellEditBinding editBinding) + { + editBinding = new SameMessageCustomEditBinding(); + return new TextBox(); + } + + protected override object PrepareCellForEdit( + Control editingElement, + Avalonia.Interactivity.RoutedEventArgs editingEventArgs) + { + return null!; + } + } + + private sealed class SameMessageCustomEditBinding : ICellEditBinding + { + public bool IsValid => true; + + public IEnumerable ValidationErrors => new[] + { + new DataValidationException( + new DataGridValidationResult( + SameMessageValidationItem.ErrorMessage, + DataGridValidationSeverity.Warning)) + }; + + public IObservable ValidationChanged { get; } = new EmptyValidationObservable(); + + public bool HasSourceWriteError => true; + + public bool CommitEdit() => true; + + public void Dispose() + { + } + } + + private sealed class MixedSourceValidationColumn : DataGridComboBoxColumn + { + public MixedSourceValidationColumn() + { + Header = "Warning"; + SelectedValueBinding = TwoWayBinding(nameof(MixedValidationItem.WarningValue)); + } + + protected override Control GenerateEditingElement( + DataGridCell cell, + object dataItem, + out ICellEditBinding editBinding) + { + editBinding = new MixedSourceEditBinding(); + return new TextBox(); + } + + protected override object PrepareCellForEdit( + Control editingElement, + Avalonia.Interactivity.RoutedEventArgs editingEventArgs) + { + return null!; + } + } + + private sealed class MixedSourceEditBinding : ICellEditBinding + { + public bool IsValid => true; + + public IEnumerable ValidationErrors => new Exception[] + { + new DataValidationException( + new DataGridValidationResult("Model warning.", DataGridValidationSeverity.Warning)), + new DataValidationException( + new DataGridValidationResult( + "Warning value should be at least 3 characters.", + DataGridValidationSeverity.Warning)) + }; + + public IObservable ValidationChanged { get; } = new EmptyValidationObservable(); + + public bool CommitEdit() => true; + + public void Dispose() + { + } + } + + private sealed class EmptyValidationObservable : IObservable + { + public IDisposable Subscribe(IObserver observer) => EmptyValidationDisposable.Instance; + } + + private sealed class EmptyValidationDisposable : IDisposable + { + public static EmptyValidationDisposable Instance { get; } = new(); + + public void Dispose() + { + } + } + private sealed class ExceptionNotifyValidationItem : INotifyPropertyChanged, INotifyDataErrorInfo { private string _code = string.Empty; diff --git a/src/Avalonia.Controls.DataGrid/DataGrid.Editing.cs b/src/Avalonia.Controls.DataGrid/DataGrid.Editing.cs index 43313568..29722718 100644 --- a/src/Avalonia.Controls.DataGrid/DataGrid.Editing.cs +++ b/src/Avalonia.Controls.DataGrid/DataGrid.Editing.cs @@ -346,6 +346,12 @@ private bool BeginCellEdit(RoutedEventArgs editingEventArgs) // Finally, we can prepare the cell for editing _editingCellValidationSnapshot = CellValidationSnapshot.Capture(dataGridCell); + _editingCellHadNotifyDataErrorInfoValidation = + _notifyDataErrorInfoCellErrors.ContainsKey(dataGridCell); + _editingCellPreservedValidationErrors = _editingCellHadNotifyDataErrorInfoValidation + ? GetErrorsWithoutOwnedNotifyDataErrorInfoErrors(dataGridCell).ToArray() + : null; + _notifyDataErrorInfoCellErrors.Remove(dataGridCell); // Hide existing cell errors while editing to avoid duplicate validation visuals. DataValidationErrors.ClearErrors(dataGridCell); _editingColumnIndex = CurrentColumnIndex; @@ -497,16 +503,46 @@ private bool EndCellEdit(DataGridEditAction editAction, bool exitEditingMode, bo // If we're committing, explicitly update the source but watch out for any validation errors if (editAction == DataGridEditAction.Commit) { - void SetValidationStatus(ICellEditBinding binding) + DataGridValidationSeverity SetValidationStatus(ICellEditBinding binding) { - var severity = ValidationUtil.GetValidationSeverity(binding.ValidationErrors); + var bindingErrors = binding.ValidationErrors.ToList(); + var notifyDataErrorInfoErrors = editingCell is null + ? new List() + : GetNotifyDataErrorInfoValidationExceptions( + editingRow?.DataContext, + editingCell.OwningColumn); + var displayedErrors = new List(); + if (_editingCellPreservedValidationErrors is not null) + { + displayedErrors.AddRange(_editingCellPreservedValidationErrors); + } + + if (notifyDataErrorInfoErrors.Count > 0 && !binding.HasSourceWriteError) + { + RemoveMatchingValidationErrors(bindingErrors, notifyDataErrorInfoErrors); + } + if (bindingErrors.Count > 0) + { + displayedErrors.Add(new AggregateException(bindingErrors)); + } + + if (notifyDataErrorInfoErrors.Count > 0) + { + object ownedError = notifyDataErrorInfoErrors.Count == 1 + ? notifyDataErrorInfoErrors[0] + : new AggregateException(notifyDataErrorInfoErrors); + displayedErrors.Add(ownedError); + _notifyDataErrorInfoCellErrors[editingCell] = new object[] { ownedError }; + } + else if (editingCell is not null) + { + _notifyDataErrorInfoCellErrors.Remove(editingCell); + } + + var severity = GetDisplayedValidationSeverity(displayedErrors); if (severity == DataGridValidationSeverity.None) { ResetValidationStatus(editingCell); - if (editingElement != null) - { - DataValidationErrors.ClearErrors(editingElement); - } } else { @@ -519,44 +555,36 @@ void SetValidationStatus(ICellEditBinding binding) } UpdateGridValidationState(); - if (editingElement != null) - { - DataValidationErrors.ClearErrors(editingElement); - } - if (editingCell != null) { - DataValidationErrors.SetError(editingCell, - new AggregateException(binding.ValidationErrors)); + DataValidationErrors.SetErrors(editingCell, displayedErrors); } } + + if (editingElement != null) + { + DataValidationErrors.ClearErrors(editingElement); + } + + return severity; } var editBinding = CurrentColumn?.CellEditBinding; if (editBinding != null) { editBinding.CommitEdit(); - var severity = ValidationUtil.GetValidationSeverity(editBinding.ValidationErrors); - if (severity != DataGridValidationSeverity.None) + var severity = SetValidationStatus(editBinding); + if (severity == DataGridValidationSeverity.Error) { - SetValidationStatus(editBinding); - - if (severity == DataGridValidationSeverity.Error) - { - _validationSubscription?.Dispose(); - _validationSubscription = editBinding.ValidationChanged.Subscribe(v => SetValidationStatus(editBinding)); - - ScrollSlotIntoView(CurrentColumnIndex, CurrentSlot, forCurrentCellChange: false, forceHorizontalScroll: true); - return false; - } - _validationSubscription?.Dispose(); - _validationSubscription = null; - } - else - { - ResetValidationStatus(editingCell); + _validationSubscription = editBinding.ValidationChanged.Subscribe(v => SetValidationStatus(editBinding)); + + ScrollSlotIntoView(CurrentColumnIndex, CurrentSlot, forCurrentCellChange: false, forceHorizontalScroll: true); + return false; } + + _validationSubscription?.Dispose(); + _validationSubscription = null; } else { @@ -586,6 +614,8 @@ void SetValidationStatus(ICellEditBinding binding) } } _editingCellValidationSnapshot = null; + _editingCellHadNotifyDataErrorInfoValidation = false; + _editingCellPreservedValidationErrors = null; if (exitEditingMode) { @@ -1061,6 +1091,8 @@ private void ResetEditingRow() EditingRow = null; _editingRowValidationSnapshot = null; _editingCellValidationSnapshot = null; + _editingCellHadNotifyDataErrorInfoValidation = false; + _editingCellPreservedValidationErrors = null; } @@ -1415,6 +1447,8 @@ virtual void OnRowEditEnding(DataGridRowEditEndingEventArgs e) private bool _focusEditingControl; private List _editingRowValidationSnapshot; private CellValidationSnapshot _editingCellValidationSnapshot; + private bool _editingCellHadNotifyDataErrorInfoValidation; + private object[] _editingCellPreservedValidationErrors; /// diff --git a/src/Avalonia.Controls.DataGrid/DataGrid.Validation.cs b/src/Avalonia.Controls.DataGrid/DataGrid.Validation.cs index 58032cf9..3a24ac65 100644 --- a/src/Avalonia.Controls.DataGrid/DataGrid.Validation.cs +++ b/src/Avalonia.Controls.DataGrid/DataGrid.Validation.cs @@ -56,8 +56,10 @@ private void ResetValidationStatus(DataGridCell editingCell = null) _validationSubscription = null; } - private static void ClearCellValidation(DataGridCell cell) + private void ClearCellValidation(DataGridCell cell) { + _notifyDataErrorInfoCellErrors.Remove(cell); + if (!cell.IsValid || cell.ValidationSeverity != DataGridValidationSeverity.None) { cell.IsValid = true; @@ -68,7 +70,20 @@ private static void ClearCellValidation(DataGridCell cell) DataValidationErrors.ClearErrors(cell); } - private void RestoreRowValidationState(DataGridRow row, object item, bool clearIfNoIndei = true) + internal void OnCellRemovedForValidation(DataGridCell cell) + { + if (cell is not null) + { + _notifyDataErrorInfoCellErrors.Remove(cell); + } + } + + private void RestoreRowValidationState( + DataGridRow row, + object item, + bool clearIfNoIndei = true, + string propertyName = null, + int excludedColumnIndex = -1) { if (row is null) { @@ -97,6 +112,11 @@ private void RestoreRowValidationState(DataGridRow row, object item, bool clearI continue; } + if (column.Index == excludedColumnIndex) + { + continue; + } + var cell = row.Cells[column.Index]; var bindingPath = GetColumnBindingPath(column); @@ -105,21 +125,30 @@ private void RestoreRowValidationState(DataGridRow row, object item, bool clearI continue; } + if (!string.IsNullOrEmpty(propertyName) && + !string.Equals(bindingPath, propertyName, StringComparison.Ordinal)) + { + continue; + } + var errors = notifyDataErrorInfo.GetErrors(bindingPath); if (errors is null) { - ClearCellValidation(cell); + ClearNotifyDataErrorInfoValidation(cell); continue; } var exceptions = CreateValidationExceptions(errors); if (exceptions.Count == 0) { - ClearCellValidation(cell); + ClearNotifyDataErrorInfoValidation(cell); continue; } - var severity = ValidationUtil.GetValidationSeverity(exceptions); + var preservedErrors = GetErrorsWithoutOwnedNotifyDataErrorInfoErrors(cell); + var severity = MaxSeverity( + ValidationUtil.GetValidationSeverity(exceptions), + GetDisplayedValidationSeverity(preservedErrors)); cell.IsValid = severity != DataGridValidationSeverity.Error; cell.ValidationSeverity = severity; cell.UpdatePseudoClasses(); @@ -127,13 +156,116 @@ private void RestoreRowValidationState(DataGridRow row, object item, bool clearI var errorException = exceptions.Count == 1 ? exceptions[0] : new AggregateException(exceptions); - DataValidationErrors.SetError(cell, errorException); + preservedErrors.Add(errorException); + DataValidationErrors.SetErrors(cell, preservedErrors); + _notifyDataErrorInfoCellErrors[cell] = new object[] { errorException }; } UpdateRowValidationStateFromCells(row); UpdateGridValidationState(); } + private void ClearNotifyDataErrorInfoValidation(DataGridCell cell) + { + if (!_notifyDataErrorInfoCellErrors.Remove(cell, out object[] expectedErrors)) + { + return; + } + + List preservedErrors = GetErrorsWithoutOwnedErrors(cell, expectedErrors); + if (preservedErrors.Count == 0) + { + ClearCellValidation(cell); + return; + } + + DataValidationErrors.SetErrors(cell, preservedErrors); + DataGridValidationSeverity severity = GetDisplayedValidationSeverity(preservedErrors); + cell.IsValid = severity != DataGridValidationSeverity.Error; + cell.ValidationSeverity = severity; + cell.UpdatePseudoClasses(); + } + + private List GetErrorsWithoutOwnedNotifyDataErrorInfoErrors(DataGridCell cell) + { + return _notifyDataErrorInfoCellErrors.TryGetValue(cell, out object[] ownedErrors) + ? GetErrorsWithoutOwnedErrors(cell, ownedErrors) + : GetDisplayedErrors(cell); + } + + private static List GetErrorsWithoutOwnedErrors(DataGridCell cell, object[] ownedErrors) + { + List errors = GetDisplayedErrors(cell); + foreach (object ownedError in ownedErrors) + { + for (int index = 0; index < errors.Count; index++) + { + if (ReferenceEquals(errors[index], ownedError)) + { + errors.RemoveAt(index); + break; + } + } + } + + return errors; + } + + private static List GetDisplayedErrors(DataGridCell cell) + { + var errors = new List(); + IEnumerable displayedErrors = DataValidationErrors.GetErrors(cell); + if (displayedErrors is null) + { + return errors; + } + + foreach (object error in displayedErrors) + { + if (error is not null) + { + errors.Add(error); + } + } + + return errors; + } + + private static DataGridValidationSeverity GetDisplayedValidationSeverity(List errors) + { + DataGridValidationSeverity severity = DataGridValidationSeverity.None; + foreach (object error in errors) + { + severity = MaxSeverity(severity, GetDisplayedValidationSeverity(error)); + } + + return severity; + } + + private static DataGridValidationSeverity GetDisplayedValidationSeverity(object error) + { + if (error is AggregateException aggregateException) + { + DataGridValidationSeverity severity = DataGridValidationSeverity.None; + foreach (Exception innerException in aggregateException.InnerExceptions) + { + severity = MaxSeverity(severity, GetDisplayedValidationSeverity(innerException)); + } + + return severity; + } + + return ValidationUtil.GetValidationSeverity( + error as Exception ?? new DataValidationException(error)); + } + + private static DataGridValidationSeverity MaxSeverity( + DataGridValidationSeverity first, + DataGridValidationSeverity second) + { + return first >= second ? first : second; + } + private void ClearRowValidation(DataGridRow row) { if (row is null) @@ -146,6 +278,7 @@ private void ClearRowValidation(DataGridRow row) foreach (DataGridCell cell in row.Cells) { + _notifyDataErrorInfoCellErrors.Remove(cell); ClearCellValidation(cell); } @@ -478,9 +611,40 @@ private void CollectionValidationItem_ErrorsChanged(object sender, DataErrorsCha } UpdateTrackedCollectionValidationItemState(notifyDataErrorInfo); + RefreshRealizedRowValidationState(notifyDataErrorInfo, e.PropertyName); UpdateGridValidationState(); } + private void RefreshRealizedRowValidationState( + INotifyDataErrorInfo notifyDataErrorInfo, + string propertyName) + { + if (DisplayData == null) + { + return; + } + + for (int slot = DisplayData.FirstScrollingSlot; + slot > -1 && slot <= DisplayData.LastScrollingSlot; + slot++) + { + if (DisplayData.GetDisplayedElement(slot) is not DataGridRow row || + !ReferenceEquals(row.DataContext, notifyDataErrorInfo)) + { + continue; + } + + int excludedColumnIndex = ReferenceEquals(row, EditingRow) + ? _editingColumnIndex + : -1; + RestoreRowValidationState( + row, + notifyDataErrorInfo, + propertyName: propertyName, + excludedColumnIndex: excludedColumnIndex); + } + } + private void DetachCollectionValidationTracking() { foreach (var notifyDataErrorInfo in _collectionValidationTrackedItems) @@ -543,6 +707,64 @@ private static List CreateValidationExceptions(IEnumerable errors) return exceptions; } + private static List GetNotifyDataErrorInfoValidationExceptions( + object item, + DataGridColumn column) + { + if (item is not INotifyDataErrorInfo notifyDataErrorInfo) + { + return new List(); + } + + string bindingPath = GetColumnBindingPath(column); + if (string.IsNullOrWhiteSpace(bindingPath)) + { + return new List(); + } + + IEnumerable errors = notifyDataErrorInfo.GetErrors(bindingPath); + return errors is null + ? new List() + : CreateValidationExceptions(errors); + } + + private static void RemoveMatchingValidationErrors( + List bindingErrors, + List notifyDataErrorInfoErrors) + { + foreach (Exception notifyDataErrorInfoError in notifyDataErrorInfoErrors) + { + for (int index = 0; index < bindingErrors.Count; index++) + { + if (ValidationErrorsMatch(bindingErrors[index], notifyDataErrorInfoError)) + { + bindingErrors.RemoveAt(index); + break; + } + } + } + } + + private static bool ValidationErrorsMatch(Exception first, Exception second) + { + object firstData = ValidationUtil.UnpackDataValidationException(first); + object secondData = ValidationUtil.UnpackDataValidationException(second); + if (ReferenceEquals(firstData, secondData) || Equals(firstData, secondData)) + { + return true; + } + + if (firstData is DataGridValidationResult firstResult && + secondData is DataGridValidationResult secondResult) + { + return firstResult.Severity == secondResult.Severity && + string.Equals(firstResult.Message, secondResult.Message, StringComparison.Ordinal); + } + + return first.GetType() == second.GetType() && + string.Equals(first.Message, second.Message, StringComparison.Ordinal); + } + private List _bindingValidationErrors; private IDisposable _validationSubscription; @@ -550,6 +772,7 @@ private static List CreateValidationExceptions(IEnumerable errors) private bool _isValid = true; private readonly HashSet _collectionValidationTrackedItems = new(ReferenceEqualityComparer.Instance); private readonly HashSet _collectionValidationItemsWithError = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _notifyDataErrorInfoCellErrors = new(); private bool _collectionValidationStateInitialized; private bool _collectionValidationStateInvalidated = true; diff --git a/src/Avalonia.Controls.DataGrid/DataGridRow.cs b/src/Avalonia.Controls.DataGrid/DataGridRow.cs index 501bfc72..36133b80 100644 --- a/src/Avalonia.Controls.DataGrid/DataGridRow.cs +++ b/src/Avalonia.Controls.DataGrid/DataGridRow.cs @@ -647,6 +647,7 @@ private void DataGridCellCollection_CellAdded(object sender, DataGridCellEventAr private void DataGridCellCollection_CellRemoved(object sender, DataGridCellEventArgs e) { + OwningGrid?.OnCellRemovedForValidation(e.Cell); _cellsElement?.Children.Remove(e.Cell); } diff --git a/src/Avalonia.Controls.DataGrid/Utils/CellEditBinding.cs b/src/Avalonia.Controls.DataGrid/Utils/CellEditBinding.cs index bae4560e..e6644460 100644 --- a/src/Avalonia.Controls.DataGrid/Utils/CellEditBinding.cs +++ b/src/Avalonia.Controls.DataGrid/Utils/CellEditBinding.cs @@ -7,6 +7,7 @@ using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Data; +using Avalonia.Data.Core; using Avalonia.PropertyStore; using Avalonia.Reactive; @@ -23,6 +24,17 @@ interface ICellEditBinding bool IsValid { get; } IEnumerable ValidationErrors { get; } IObservable ValidationChanged { get; } + + /// + /// Gets a value indicating whether the current validation errors were produced by a + /// failed write to the binding source. + /// + /// + /// Custom edit bindings should return while exposing source-write + /// errors so the grid does not conflate them with equivalent model validation errors. + /// + bool HasSourceWriteError => false; + bool CommitEdit(); } @@ -32,6 +44,7 @@ internal abstract class CellEditBindingBase : ICellEditBinding private readonly AvaloniaProperty _property; private readonly string _bindingPath; private readonly bool _supportsDirectSourceWriteFallback; + private readonly bool _supportsSourceWriteResult; private readonly LightweightSubject _changedSubject = new(); private readonly List _validationErrors = new(); private DataGridValidationSeverity _validationSeverity = DataGridValidationSeverity.None; @@ -42,6 +55,8 @@ protected CellEditBindingBase(AvaloniaObject target, AvaloniaProperty property, _target = target ?? throw new ArgumentNullException(nameof(target)); _property = property ?? throw new ArgumentNullException(nameof(property)); _supportsDirectSourceWriteFallback = BindingCloneHelper.SupportsDirectDataContextMemberWrite(binding); + _supportsSourceWriteResult = BindingCloneHelper.GetMode(binding) is + BindingMode.TwoWay or BindingMode.OneWayToSource; _bindingPath = _supportsDirectSourceWriteFallback && binding != null ? BindingCloneHelper.GetPath(binding) : null; @@ -52,9 +67,11 @@ protected CellEditBindingBase(AvaloniaObject target, AvaloniaProperty property, public bool IsValid => _validationSeverity != DataGridValidationSeverity.Error; public IEnumerable ValidationErrors => _validationErrors; public IObservable ValidationChanged => _changedSubject; + public bool HasSourceWriteError { get; private set; } public bool CommitEdit() { + HasSourceWriteError = false; Exception commitError = null; var expression = BindingOperations.GetBindingExpressionBase(_target, _property); var preCommitValidationErrors = CaptureValidationErrors(expression); @@ -66,11 +83,21 @@ expression is IValueEntry preCommitValueEntry && { try { - expression.UpdateSource(); + if (_supportsSourceWriteResult && + expression is UntypedBindingExpressionBase untypedExpression) + { + HasSourceWriteError = !untypedExpression.WriteValueToSource( + _target.GetValue(_property)); + } + else + { + expression.UpdateSource(); + } } catch (Exception ex) { commitError = ex; + HasSourceWriteError = true; } } @@ -96,6 +123,7 @@ expression is IValueEntry writeValueEntry && usedToggleSwitchFallback = true; if (writeError != null) { + HasSourceWriteError = true; UpdateValidationErrorsFromException(writeError); } else