Ticket T1287323
Visible to All Users

Custom WPF PropertyGrid Editor Ignores custom range validation error message

created 12 days ago (modified 12 days ago)

The attached application shows what the problem is. I built a custom unit system aware input control that converts model values between metric and imperial unit system. After a lot of experimentation and wrestling myself through hundreds of blog posts, support messages etc. I finally found a way to make it work, except for one crucial detail: displaying the correct validation message "value must be between "a"(in/mm) and "b"(in/mm)

I created a custom UnitRangeAttribute class that handles the validation logic, and I kind of expected this to be picked up by my custom control template, but it doesn't. I suspect this is because I can't use the x:Name="PART_Editor" on my template, because of the custom EditValue binding.

The example App shows an entity model with two properties, one with and one without the custom CellTemplate applied. The latter shows the correct message, the former doesn't

For good measure, here's the CellTemplate I'm talking about.

XAML
<DataTemplate x:Key="UnitsAwareInputEditorTemplate"> <dxe:SpinEdit HorizontalAlignment="Stretch" HorizontalContentAlignment="Left" EditValue="{Binding Value, Converter={converters:LengthUnitConverter}}" Mask="{Binding UnitsFormatString, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type local:MainWindow}}}" MaskType="Numeric" MaskUseAsDisplayFormat="True" MaxValue="{Binding Converter={converters:RangeAttributeConverter}, ConverterParameter=Max}" MinValue="{Binding Converter={converters:RangeAttributeConverter}, ConverterParameter=Min}" ShowBorder="False" /> </DataTemplate>

And this is the custom attribute:

C#
[AttributeUsage(AttributeTargets.Property)] public class UnitRangeAttribute : ValidationAttribute { private readonly UnitSystem _unitSystem = UnitSystemProvider.FromDiContainer; private readonly double _minimum; private readonly double _maximum; public UnitRangeAttribute(double minimum, double maximum) { _minimum = minimum; _maximum = maximum; ErrorMessage = $"Value for {0} is not in the expected range {Minimum} - {Maximum}"; } public double Minimum => UnitSets.Length.FromMetric(_minimum, _unitSystem); public double Maximum => UnitSets.Length.FromMetric(_maximum, _unitSystem); public override bool IsValid(object value) { if (value is not double doubleValue) throw new ArgumentException("Value must be a double"); double enteredValue = UnitSets.Length.FromMetric(doubleValue, _unitSystem); return enteredValue >= Minimum && enteredValue <= Maximum; } public override string FormatErrorMessage(string name) { return string.Format(ErrorMessageString, name, Minimum, Maximum); } }

I even have an alternative implementation of this Attribute commented out in the attached source code, you can have a look at it, but it also did'nt work.

I don't know what I'm missing, so please help me out here!

Much appreciated!

Kind regards,

Gert

Comments (2)
Alexander D (DevExpress Support) 12 days ago

    Hello Gert,

    Thank you for the sample project.

    To synchronize the editor with PropertyGridControl and utilize its validation mechanism, you need to name SpinEdit as PART_Editor, even if you bind the EditValue property. This appears to be the most straightforward solution for your task.

    That being said, since you define MinValue and MaxValue at the editor level, the editor's internal validation overrides PropertyGridControl's logic even in this case. To utilize custom validation instead, you need to remove MinValue and MaxValue definitions:

    XAML
    <DataTemplate x:Key="UnitsAwareInputEditorTemplate"> <dxe:SpinEdit x:Name="PART_Editor" HorizontalAlignment="Stretch" HorizontalContentAlignment="Left" EditValue="{Binding Value, Converter={converters:LengthUnitConverter}}" Mask="{Binding UnitsFormatString, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type local:MainWindow}}}" MaskType="Numeric" MaskUseAsDisplayFormat="True" ShowBorder="False" /> </DataTemplate>

    Please let me know if this helps.

    Regards,
    Alexander

      Hi Alexander,

      Thanks for the quick reply. If it were only this simple . For good measure, I have tried your suggestion, but sadly it does not work.

      1. If you add the PART_Editor back, the whole binding is screwed up, because of the converter applied to it. Try it yourself. Change the value in the Height field, leave it to commit the value and then come back to it, notice what happens to the value. Note: the debug output shows the value that gets written into the Entity model property, monitor it when you're changing the value.

      2. If you remove the min and max value, the spin-edit no longer stops at the min and max values. Also, the bindings are necessary for the min and max values to work in the designated unit system, so between 100 and 400 in metric and 3.xxx and 15.xxx when in imperial

      3. Please also note that both manual data entry and using the up/down buttons on the spin edit control should be supported.

      Trust me, I worked on this for days and have a few bald spots on my head as a result of pulling my hair out while trying to get all these requirements to work at the same time.

      I'm very interested to see if there is an easy way to get this all working. Please make me happy

      Kind regards,

      Gert

      Answers approved by DevExpress Support

      created 11 days ago

      Hello Gert,

      Thank you for the clarification.

      Please accept my apologies for not testing the solution properly. The PART_Editor name has to be removed if you utilize a converter in this use case. This will not allow the editor to utilize PropertyGridControl's validation mechanism. To keep using this mechanism, you can create an additional view model layer between your Entity and the UI. In this case, you can use it as a proxy to convert values, in which case a UI converter will no longer be required and you can utilize an in-place editor without a bound EditValue.


      To keep MinValue and MaxValue definitions, you can customize the default error tooltip. We discussed how to accomplish this in the following public ticket: Q557363 - Validation and custom error text for spin edit. This appears to be the most straightforward solution for your task.


      We also demonstrated how to restrict values without these properties in the following public ticket: T1019641 - ButtonEdit with TimeSpan mask supporting SpinEdit's Increment/MinValue/MaxValue?. This is a valid alternative if you decide to use a single validation mechanism to avoid logic duplication. Since this approach requires processing a UI event, it may be suitable to encapsulate this solution in a custom behavior. See: Create a Custom Behavior.


      You can use the editor's validation with the current implementation. In this case, use the Range attribute instead of UnitRange. See: Attribute-Based Validation.

      C#
      //[UnitRange(100, 400, ErrorMessage = "This message is also ignored")] [Range(100, 400, ErrorMessage = "This message is also ignored")] public double Height { ...

      Please let me know if this helps.

      Regards,
      Alexander

        Comments (2)

          Hi Alexander,

          This actually helped me get it working. Specifically the solution provided Here: (Q557363).

          My real project does not allow me to implement an extra view model layer in between and therefor it's not a viable option for my use case. The normal RangeAttribute is obviously also not usable, as it does not allow me to provide the unit conversion in the error message. I could have chosen to implement this directly in the CustomErrorTooltipConverter (see code below) as well, but I like the flexibility of configuring my validation annotations with this specifically targeted and well defined attribute that uses its own default ErrorMessage format.

          For other wanderers that have similar requirements I'll try to briefly explain how it all ties together:

          MainWindow:

          MainWindow.xaml defines the PropertyGridControl and references the SharedResource that defines the custom editor templates used.

          The backend code binds the PropertyGridControl to our Entity. It also sets up the custom editor for the Height property as well.

          XAML
          <Window x:Class="WpfApplication1.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:dxprg="http://schemas.devexpress.com/winfx/2008/xaml/propertygrid" Title="MainWindow" Width="525" Height="350"> <Window.Resources> <ResourceDictionary> <ResourceDictionary.MergedDictionaries> <ResourceDictionary Source="SharedResources.xaml" /> </ResourceDictionary.MergedDictionaries> </ResourceDictionary> </Window.Resources> <Grid> <dxprg:PropertyGridControl Name="Pg" AllowCommitOnValidationAttributeError="True" ShowCategories="Hidden" ShowEditorButtons="True" ShowMenuButtonInRows="False" ShowProperties="All" ShowToolPanel="False" UseCollectionEditor="True" /> </Grid> </Window>
          C#
          using DevExpress.Mvvm.DataAnnotations; using WpfApplication1.Units; namespace WpfApplication1; public partial class MainWindow { public MainWindow() { InitializeComponent(); MetadataLocator.Default = MetadataLocator .Create() .AddMetadata<EntityMetadata>(); Pg.SelectedObject = new Entity(); } public class EntityMetadata { public static void BuildMetadata(MetadataBuilder<Entity> builder) { builder .Property(e => e.Height) .PropertyGridEditor("UnitsAwareInputEditor") ; } } public static string UnitsFormatString => UnitSystemProvider.FromDiContainer == UnitSystem.Imperial ? $"##0.### {UnitSets.Length.ImperialAbbreviation}" : $"##0.# {UnitSets.Length.MetricAbbreviation}"; }

          Entity.cs:

          This is the entity model we are binding the Property Grid to

          Code
          using System.Diagnostics; using WpfApplication1.Attributes; namespace WpfApplication1; public class Entity { private double _height; private double _width; [UnitRange(400, 800)] // this uses the default error message public double Width { get => _width; set { _width = value; Debug.WriteLine($"Width value stored as {_width}"); } } [UnitRange(100, 400, ErrorMessage = "'{0}' should be between {1} and {2}{3}")] // this uses a custom error message public double Height { get => _height; set { _height = value; Debug.WriteLine($"Height value stored as {_height}"); } } }

          UnitRangeAttribute.cs

          This is the custom range attribute we can use to do our customized validation

          Code
          using System; using System.ComponentModel.DataAnnotations; using WpfApplication1.Units; namespace WpfApplication1.Attributes; [AttributeUsage(AttributeTargets.Property)] public class UnitRangeAttribute : ValidationAttribute { private readonly UnitSystem _unitSystem = UnitSystemProvider.FromDiContainer; private readonly double _minimum; private readonly double _maximum; public UnitRangeAttribute(double minimum, double maximum) { _minimum = minimum; _maximum = maximum; ErrorMessage = $"Value for {{0}} is not in the expected range {Minimum}{Abbrev} - {Maximum}{Abbrev}"; } public double Minimum => UnitSets.Length.FromMetric(_minimum, _unitSystem); public double Maximum => UnitSets.Length.FromMetric(_maximum, _unitSystem); public string Abbrev => UnitSets.Length.GetAbbreviation(_unitSystem); public override bool IsValid(object value) { if (value is not double doubleValue) throw new ArgumentException("Value must be a double"); double enteredValue = UnitSets.Length.FromMetric(doubleValue, _unitSystem); return enteredValue >= Minimum && enteredValue <= Maximum; } public override string FormatErrorMessage(string name) { return string.Format(ErrorMessageString, name, Minimum, Maximum, Abbrev); } }

          SharedResource.xaml:

          This file contains the templates for the custom control. It makes heavy use of converters to get the required custom behavior. They depend on the UnitRangeAttribute to provide the correct value representations.

          XAML
          <ResourceDictionary x:Class="WpfApplication1.SharedResources" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:converters="clr-namespace:WpfApplication1.Converters" xmlns:dxe="http://schemas.devexpress.com/winfx/2008/xaml/editors" xmlns:dxprg="http://schemas.devexpress.com/winfx/2008/xaml/propertygrid" xmlns:local="clr-namespace:WpfApplication1"> <ResourceDictionary.MergedDictionaries> <ResourceDictionary> <DataTemplate x:Key="CustomErrorTooltipTemplate"> <TextBlock Text="{Binding Converter={converters:CustomErrorTooltipConverter}, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=dxe:SpinEdit}}" /> </DataTemplate> <DataTemplate x:Key="UnitsAwareInputEditorTemplate"> <dxe:SpinEdit HorizontalAlignment="Stretch" HorizontalContentAlignment="Left" EditValue="{Binding Value, Converter={converters:LengthUnitConverter}}" ErrorToolTipContentTemplate="{DynamicResource CustomErrorTooltipTemplate}" Mask="{Binding UnitsFormatString, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type local:MainWindow}}}" MaskType="Numeric" MaskUseAsDisplayFormat="True" MaxValue="{Binding Converter={converters:RangeAttributeConverter}, ConverterParameter=Max}" MinValue="{Binding Converter={converters:RangeAttributeConverter}, ConverterParameter=Min}" ShowBorder="False" /> </DataTemplate> <DataTemplate x:Key="UnitsAwareInputEditor"> <dxprg:PropertyDefinition CellTemplate="{DynamicResource UnitsAwareInputEditorTemplate}" ExpandButtonVisibility="Visible" IsReadOnly="False" PostOnEditValueChanged="False" /> </DataTemplate> </ResourceDictionary> </ResourceDictionary.MergedDictionaries> </ResourceDictionary>

          Converters

          Last but not least, the converters that tie the whole thing together.

          LengthUnitConverter:

          Converts the input value to the required UnitSystem. The actual conversion is done in a dedicated UnitSet class that you'll find in the attached download.

          C#
          using System; using System.Globalization; using System.Windows.Data; using System.Windows.Markup; using WpfApplication1.Units; namespace WpfApplication1.Converters; /// <summary> /// Converter to convert double values to and from values, stored in mm to /// specified unit system (metric or imperial) /// </summary> [ValueConversion(typeof(double), typeof(double), ParameterType = typeof(UnitSystem))] public class LengthUnitConverter : MarkupExtension, IValueConverter { /// <summary> /// Convert value a double value in in the the specified or current application unitsystem /// </summary> public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { if (TryConvertToDouble(value, out double dblValue, culture) == false) throw new ArgumentException("Value must be a double or convertible to a double"); // if a valid unit system is not provided, we'll resolve it from our DI container if (parameter is not UnitSystem unitSystem) unitSystem = UnitSystemProvider.FromDiContainer; return UnitSets.Length.FromMetric(dblValue, unitSystem); } /// <summary> /// Convert input back to double value in metric units /// </summary> public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { if (TryConvertToDouble(value, out double dblValue, culture) == false) throw new ArgumentException("Value must be a double or convertible to a double"); if (parameter is not UnitSystem unitSystem) // a valid unit system is not provided, we'll assume metric unitSystem = UnitSystemProvider.FromDiContainer; return UnitSets.Length.AsMetric(dblValue, unitSystem); } private static bool TryConvertToDouble(object value, out double dblValue, CultureInfo culture) { try { dblValue = System.Convert.ToDouble(value, culture); return true; } catch { dblValue = double.NaN; return false; } } public override object ProvideValue(IServiceProvider serviceProvider) { return this; } }

          RangeAttributeConverter:

          This converter is used to provide the unit system specific MinValue and MaxValue values for the SpinEdit control,

          C#
          using DevExpress.Xpf.PropertyGrid; using System; using System.Globalization; using System.Reflection; using System.Windows; using System.Windows.Data; using System.Windows.Markup; using WpfApplication1.Attributes; namespace WpfApplication1.Converters; /// <summary> /// This converter makes sure that validation happens in the correct units system /// </summary> public class RangeAttributeConverter : MarkupExtension, IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { if (value is not RowData row) return DependencyProperty.UnsetValue; object entity = row.Parent.Value; var rangeAttr = entity?.GetType() .GetProperty(row.FullPath)? .GetCustomAttribute<UnitRangeAttribute>(); if (rangeAttr != null) { return parameter?.ToString() == "Min" ? rangeAttr.Minimum : rangeAttr.Maximum; } return DependencyProperty.UnsetValue; } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotSupportedException(); public override object ProvideValue(IServiceProvider serviceProvider) { return this; } }

          CustomErrorTooltipConverter:

          Provides the formatted error message ToolTip, taken from the UnitRangeAttribute

          C#
          using DevExpress.Xpf.Editors; using DevExpress.Xpf.PropertyGrid; using System; using System.Reflection; using System.Windows; using System.Windows.Data; using System.Windows.Markup; using WpfApplication1.Attributes; namespace WpfApplication1.Converters; [ValueConversion(typeof(SpinEdit), typeof(string))] public class CustomErrorTooltipConverter : MarkupExtension, IValueConverter { public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) { if (value is not SpinEdit se || se.DataContext is not RowData row) return DependencyProperty.UnsetValue; object entity = row.Parent.Value; UnitRangeAttribute rangeAttr = entity?.GetType() .GetProperty(row.FullPath)? .GetCustomAttribute<UnitRangeAttribute>(); return rangeAttr != null ? rangeAttr.FormatErrorMessage(row.Header) : DependencyProperty.UnsetValue; } public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) { return value; } public override object ProvideValue(IServiceProvider serviceProvider) { return this; } }

          Hope anyone finds this useful. Please find the attached solution with the complete example attached.

          Alexander D (DevExpress Support) a day ago

            I am happy to hear that you found a solution. Thank you for sharing it.
            Alexander

            Disclaimer: The information provided on DevExpress.com and affiliated web properties (including the DevExpress Support Center) is provided "as is" without warranty of any kind. Developer Express Inc disclaims all warranties, either express or implied, including the warranties of merchantability and fitness for a particular purpose. Please refer to the DevExpress.com Website Terms of Use for more information in this regard.

            Confidential Information: Developer Express Inc does not wish to receive, will not act to procure, nor will it solicit, confidential or proprietary materials and information from you through the DevExpress Support Center or its web properties. Any and all materials or information divulged during chats, email communications, online discussions, Support Center tickets, or made available to Developer Express Inc in any manner will be deemed NOT to be confidential by Developer Express Inc. Please refer to the DevExpress.com Website Terms of Use for more information in this regard.