From 8fc0be82cdfa9e2b3e3f7d4eb5edf6619adbdb7a Mon Sep 17 00:00:00 2001 From: Tim <47110241+timunie@users.noreply.github.com> Date: Tue, 7 Nov 2023 03:17:07 +0100 Subject: [PATCH] Converter for DataValidationErrors (#11282) * Introduce ErrorConverter and DisplayErrors attached properties - the converter can be used to change the way a message is print - we use DisplayErrors to get the converted error messages * Adjust FluentTheme * [WIP] Add a sample Page for DataValidationErrors * use a private attached property to store recent errors this approach gets rid of the need to DisplayErrors property * Update samples with some additional details * Reuse rich SetError logic in DataGrid as well * Unify some code with OnErrorsOrConverterChanged * Restore old behavior with null default value * Add SetErrorConverter test --------- Co-authored-by: Max Katz --- samples/ControlCatalog/MainView.xaml | 3 + .../Pages/DataValidationPage.axaml | 42 ++++++++ .../Pages/DataValidationPage.axaml.cs | 14 +++ .../ViewModels/DataValidationViewModel.cs | 45 +++++++++ src/Avalonia.Controls.DataGrid/DataGrid.cs | 9 +- src/Avalonia.Controls/DataValidationErrors.cs | 99 +++++++++++++++---- .../TextBoxTests_DataValidation.cs | 24 +++++ 7 files changed, 208 insertions(+), 28 deletions(-) create mode 100644 samples/ControlCatalog/Pages/DataValidationPage.axaml create mode 100644 samples/ControlCatalog/Pages/DataValidationPage.axaml.cs create mode 100644 samples/ControlCatalog/ViewModels/DataValidationViewModel.cs diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml index 2120b03b20..e19b563fb7 100644 --- a/samples/ControlCatalog/MainView.xaml +++ b/samples/ControlCatalog/MainView.xaml @@ -74,6 +74,9 @@ ScrollViewer.VerticalScrollBarVisibility="Disabled"> + + + diff --git a/samples/ControlCatalog/Pages/DataValidationPage.axaml b/samples/ControlCatalog/Pages/DataValidationPage.axaml new file mode 100644 index 0000000000..d46562addd --- /dev/null +++ b/samples/ControlCatalog/Pages/DataValidationPage.axaml @@ -0,0 +1,42 @@ + + + + + + + + diff --git a/samples/ControlCatalog/Pages/DataValidationPage.axaml.cs b/samples/ControlCatalog/Pages/DataValidationPage.axaml.cs new file mode 100644 index 0000000000..e38f85fec4 --- /dev/null +++ b/samples/ControlCatalog/Pages/DataValidationPage.axaml.cs @@ -0,0 +1,14 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace ControlCatalog.Pages; + +public partial class DataValidationPage : UserControl +{ + public DataValidationPage() + { + InitializeComponent(); + } +} + diff --git a/samples/ControlCatalog/ViewModels/DataValidationViewModel.cs b/samples/ControlCatalog/ViewModels/DataValidationViewModel.cs new file mode 100644 index 0000000000..3b2c37699c --- /dev/null +++ b/samples/ControlCatalog/ViewModels/DataValidationViewModel.cs @@ -0,0 +1,45 @@ +using System; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using MiniMvvm; + +namespace ControlCatalog.ViewModels; + +public class DataValidationViewModel : ViewModelBase +{ + private string? _DataAnnotationsSample; + + [Required] + [EmailAddress] + [MinLength(5)] + public string? DataAnnotationsSample + { + get => _DataAnnotationsSample; + set => RaiseAndSetIfChanged(ref _DataAnnotationsSample, value); + } + + public Func Converter { get; } = new Func(o => + { + return $"Error: {o}"; + }); + + + private string? _ExceptionInsideSetterSample; + + public string? ExceptionInsideSetterSample + { + get => _ExceptionInsideSetterSample; + set + { + if (value is null || value.Length < 5) + throw new ArgumentOutOfRangeException(nameof(value), "Give me 5 or more letter please :-)"); + + RaiseAndSetIfChanged(ref _ExceptionInsideSetterSample, value); + } + } + + public Func ExceptionConverter { get; } = new Func(o => + { + return o is Exception ex ? $"Huh, there was an Exception: {ex.Message}" : "Something went really wrong!"; + }); +} diff --git a/src/Avalonia.Controls.DataGrid/DataGrid.cs b/src/Avalonia.Controls.DataGrid/DataGrid.cs index d7d50f1d6e..88270ee5cc 100644 --- a/src/Avalonia.Controls.DataGrid/DataGrid.cs +++ b/src/Avalonia.Controls.DataGrid/DataGrid.cs @@ -4160,13 +4160,8 @@ namespace Avalonia.Controls if (editingElement != null) { - var errorList = - binding.ValidationErrors - .SelectMany(ValidationUtil.UnpackException) - .Select(ValidationUtil.UnpackDataValidationException) - .ToList(); - - DataValidationErrors.SetErrors(editingElement, errorList); + DataValidationErrors.SetError(editingElement, + new AggregateException(binding.ValidationErrors)); } } } diff --git a/src/Avalonia.Controls/DataValidationErrors.cs b/src/Avalonia.Controls/DataValidationErrors.cs index 4f84a303ea..243032e725 100644 --- a/src/Avalonia.Controls/DataValidationErrors.cs +++ b/src/Avalonia.Controls/DataValidationErrors.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using Avalonia.Reactive; @@ -18,6 +18,8 @@ namespace Avalonia.Controls [PseudoClasses(":error")] public class DataValidationErrors : ContentControl { + private static bool s_overridingErrors; + /// /// Defines the DataValidationErrors.Errors attached property. /// @@ -29,10 +31,24 @@ namespace Avalonia.Controls /// public static readonly AttachedProperty HasErrorsProperty = AvaloniaProperty.RegisterAttached("HasErrors"); + + /// + /// Defines the DataValidationErrors.ErrorConverter attached property. + /// + public static readonly AttachedProperty?> ErrorConverterProperty = + AvaloniaProperty.RegisterAttached?>("ErrorConverter"); + /// + /// Defines the DataValidationErrors.ErrorTemplate property. + /// public static readonly StyledProperty ErrorTemplateProperty = AvaloniaProperty.Register(nameof(ErrorTemplate)); + /// + /// Stores the original, not converted errors passed by the control + /// + private static readonly AttachedProperty?> OriginalErrorsProperty = + AvaloniaProperty.RegisterAttached?>("OriginalErrors"); private Control? _owner; @@ -56,6 +72,12 @@ namespace Avalonia.Controls ErrorsProperty.Changed.Subscribe(ErrorsChanged); HasErrorsProperty.Changed.Subscribe(HasErrorsChanged); TemplatedParentProperty.Changed.AddClassHandler((x, e) => x.OnTemplatedParentChange(e)); + ErrorConverterProperty.Changed.Subscribe(OnErrorConverterChanged); + } + + private static void OnErrorConverterChanged(AvaloniaPropertyChangedEventArgs e) + { + OnErrorsOrConverterChanged((Control)e.Sender); } private void OnTemplatedParentChange(AvaloniaPropertyChangedEventArgs e) @@ -74,15 +96,17 @@ namespace Avalonia.Controls private static void ErrorsChanged(AvaloniaPropertyChangedEventArgs e) { + if (s_overridingErrors) return; + var control = (Control)e.Sender; var errors = (IEnumerable?)e.NewValue; - var hasErrors = false; - if (errors != null && errors.Any()) - hasErrors = true; + // Update original errors + control.SetValue(OriginalErrorsProperty, errors); - control.SetValue(HasErrorsProperty, hasErrors); + OnErrorsOrConverterChanged(control); } + private static void HasErrorsChanged(AvaloniaPropertyChangedEventArgs e) { var control = (Control)e.Sender; @@ -100,8 +124,35 @@ namespace Avalonia.Controls } public static void SetError(Control control, Exception? error) { - SetErrors(control, UnpackException(error)); + SetErrors(control, UnpackException(error)? + .Select(UnpackDataValidationException) + .Where(e => e is not null) + .ToArray()!); + } + + private static void OnErrorsOrConverterChanged(Control control) + { + var converter = GetErrorConverter(control); + var originalErrors = control.GetValue(OriginalErrorsProperty); + var newErrors = (converter is null ? + originalErrors : + originalErrors?.Select(converter) + .Where(e => e is not null))? + .ToArray(); + + s_overridingErrors = true; + try + { + control.SetCurrentValue(ErrorsProperty, newErrors!); + } + finally + { + s_overridingErrors = false; + } + + control.SetValue(HasErrorsProperty, newErrors?.Any() == true); } + public static void ClearErrors(Control control) { SetErrors(control, null); @@ -111,30 +162,36 @@ namespace Avalonia.Controls return control.GetValue(HasErrorsProperty); } - private static IEnumerable? UnpackException(Exception? exception) + public static Func? GetErrorConverter(Control control) + { + return control.GetValue(ErrorConverterProperty); + } + + public static void SetErrorConverter(Control control, Func? converter) + { + control.SetValue(ErrorConverterProperty, converter); + } + + private static IEnumerable? UnpackException(Exception? exception) { if (exception != null) { - var aggregate = exception as AggregateException; - var exceptions = aggregate == null ? - new[] { GetExceptionData(exception) } : - aggregate.InnerExceptions.Select(GetExceptionData).ToArray(); - var filtered = exceptions.Where(x => !(x is BindingChainException)).ToList(); - - if (filtered.Count > 0) - { - return filtered; - } + var exceptions = exception is AggregateException aggregate ? + aggregate.InnerExceptions : + (IEnumerable)new[] { exception }; + + return exceptions.Where(x => !(x is BindingChainException)).ToArray(); } return null; } - private static object GetExceptionData(Exception exception) + private static object? UnpackDataValidationException(Exception exception) { - if (exception is DataValidationException dataValidationException && - dataValidationException.ErrorData is object data) - return data; + if (exception is DataValidationException dataValidationException) + { + return dataValidationException.ErrorData; + } return exception; } diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs index 9a9c4d352e..295fc192d7 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs @@ -64,6 +64,30 @@ namespace Avalonia.Controls.UnitTests } } + [Fact] + public void Setter_Exceptions_Should_Be_Converter_If_Error_Converter_Set() + { + using (UnitTestApplication.Start(Services)) + { + var target = new TextBox + { + DataContext = new ExceptionTest(), + [!TextBox.TextProperty] = new Binding(nameof(ExceptionTest.LessThan10), BindingMode.TwoWay), + Template = CreateTemplate() + }; + DataValidationErrors.SetErrorConverter(target, err => "Error: " + err); + + target.ApplyTemplate(); + + target.Text = "20"; + + IEnumerable errors = DataValidationErrors.GetErrors(target); + Assert.Single(errors); + var error = Assert.IsType(errors.Single()); + Assert.StartsWith("Error: ", error); + } + } + [Fact] public void Setter_Exceptions_Should_Set_DataValidationErrors_HasErrors() {