Browse Source

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 <[email protected]>
Tim 2 years ago
parent
commit
8fc0be82cd

+ 3 - 0
samples/ControlCatalog/MainView.xaml

@@ -74,6 +74,9 @@
                ScrollViewer.VerticalScrollBarVisibility="Disabled">
         <pages:DataGridPage />
       </TabItem>
+      <TabItem Header="Data Validation">
+        <pages:DataValidationPage />
+      </TabItem>
       <TabItem Header="Date/Time Picker">
         <pages:DateTimePickerPage />
       </TabItem>

+ 42 - 0
samples/ControlCatalog/Pages/DataValidationPage.axaml

@@ -0,0 +1,42 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+             xmlns:viewModels="clr-namespace:ControlCatalog.ViewModels"
+             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+             x:DataType="viewModels:DataValidationViewModel"
+             x:Class="ControlCatalog.Pages.DataValidationPage">
+  <UserControl.DataContext>
+    <viewModels:DataValidationViewModel />
+  </UserControl.DataContext>
+  <StackPanel TextBlock.TextWrapping="Wrap">
+    <Label Target="Email1" Content="Email validation" />
+    <TextBox x:Name="Email1"
+             MaxWidth="400"
+             HorizontalAlignment="Left"
+             Text="{Binding DataAnnotationsSample}" />
+
+    <Label Target="Email2" Content="Email validation with custom error converter"
+           Margin="0, 10, 0, 0" />
+    <TextBox x:Name="Email2"
+             Text="{Binding DataAnnotationsSample}"
+             MaxWidth="400"
+             HorizontalAlignment="Left"
+             DataValidationErrors.ErrorConverter="{Binding Converter}" />
+    
+    <Label Target="SetterException" Content="Setter exception with custom error converter and tooltip error"
+           Margin="0, 10, 0, 0" />
+    <TextBox x:Name="SetterException"
+             Text="{Binding ExceptionInsideSetterSample}"
+             MaxWidth="400"
+             HorizontalAlignment="Left"
+             DataValidationErrors.ErrorConverter="{Binding ExceptionConverter}">
+      <TextBox.Styles>
+        <Style Selector="DataValidationErrors">
+          <Setter Property="Theme" Value="{DynamicResource TooltipDataValidationErrors}" />
+        </Style>
+      </TextBox.Styles>
+    </TextBox>
+  </StackPanel>
+</UserControl>
+

+ 14 - 0
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();
+    }
+}
+

+ 45 - 0
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<object, object> Converter { get; } = new Func<object, object>(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<object, object> ExceptionConverter { get; } = new Func<object, object>(o =>
+    {
+        return o is Exception ex ? $"Huh, there was an Exception: {ex.Message}" : "Something went really wrong!";
+    });
+}

+ 2 - 7
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));
                         }
                     }
                 }

+ 78 - 21
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;
+        
         /// <summary>
         /// Defines the DataValidationErrors.Errors attached property.
         /// </summary>
@@ -29,10 +31,24 @@ namespace Avalonia.Controls
         /// </summary>
         public static readonly AttachedProperty<bool> HasErrorsProperty =
             AvaloniaProperty.RegisterAttached<DataValidationErrors, Control, bool>("HasErrors");
+        
+        /// <summary>
+        /// Defines the DataValidationErrors.ErrorConverter attached property.
+        /// </summary>
+        public static readonly AttachedProperty<Func<object, object>?> ErrorConverterProperty =
+            AvaloniaProperty.RegisterAttached<DataValidationErrors, Control, Func<object, object>?>("ErrorConverter");
 
+        /// <summary>
+        /// Defines the DataValidationErrors.ErrorTemplate property.
+        /// </summary>
         public static readonly StyledProperty<IDataTemplate> ErrorTemplateProperty =
             AvaloniaProperty.Register<DataValidationErrors, IDataTemplate>(nameof(ErrorTemplate));
 
+        /// <summary>
+        /// Stores the original, not converted errors passed by the control
+        /// </summary>
+        private static readonly AttachedProperty<IEnumerable<object>?> OriginalErrorsProperty =
+            AvaloniaProperty.RegisterAttached<DataValidationErrors, Control, IEnumerable<object>?>("OriginalErrors");
 
         private Control? _owner;
 
@@ -56,6 +72,12 @@ namespace Avalonia.Controls
             ErrorsProperty.Changed.Subscribe(ErrorsChanged);
             HasErrorsProperty.Changed.Subscribe(HasErrorsChanged);
             TemplatedParentProperty.Changed.AddClassHandler<DataValidationErrors>((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<object>?)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<object>? UnpackException(Exception? exception)
+        public static Func<object, object?>? GetErrorConverter(Control control)
+        {
+            return control.GetValue(ErrorConverterProperty);
+        }
+
+        public static void SetErrorConverter(Control control, Func<object, object>? converter)
+        {
+            control.SetValue(ErrorConverterProperty, converter);
+        }
+
+        private static IEnumerable<Exception>? 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<Exception>)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;
         }

+ 24 - 0
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<object> errors = DataValidationErrors.GetErrors(target);
+                Assert.Single(errors);
+                var error = Assert.IsType<string>(errors.Single());
+                Assert.StartsWith("Error: ", error);
+            }
+        }
+        
         [Fact]
         public void Setter_Exceptions_Should_Set_DataValidationErrors_HasErrors()
         {