MahApps / MahApps.Metro

A framework that allows developers to cobble together a better UI for their own WPF applications with minimal effort.
https://mahapps.com
MIT License
9.32k stars 2.45k forks source link

Invalid behavior of the icon in row header in data grid when record implements INotifyDataErrorInfo #3677

Closed danielklecha closed 4 years ago

danielklecha commented 4 years ago

Description

  1. Add DataGrid
  2. Set as ItemsSource ObservableCollection with records which implement INotifyDataErrorInfo and INotifyPropertyChanged and with errors after initialization
  3. Run app
  4. Textbox has red border but icon is missing (strange but I can live with that)
  5. Double click on cell (icon in row header is shown)
  6. fix value and hit enter
  7. Textbox hasn't got red border (PASS) but icon with tooltip with original error is still there (FAIL)

Am I implemented it incorrectly? Or it's an issue in header style?

Environment

Nuget libraries

Code

using Caliburn.Micro;
using FluentValidation;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
namespace WpfDataGridValidation
{
public class MainWindowViewModel : Conductor<IScreen>.Collection.AllActive
{
    public MainWindowViewModel()
    {
        var validator = new MyRecordValidator();
        var record = new MyRecord(validator);
        Records = new ObservableCollection<MyRecord>(Enumerable.Repeat(record, 1));
    }

    public ObservableCollection<MyRecord> Records { get; set; }
}

public class MyRecord : Caliburn.Micro.PropertyChangedBase, INotifyDataErrorInfo
{
    private readonly FluentValidation.AbstractValidator<MyRecord> _validator;

    public MyRecord(FluentValidation.AbstractValidator<MyRecord> validator)
    {
        _validator = validator;
        Validate();
    }

    public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;

    public bool HasErrors => Errors.Any();

    public IEnumerable GetErrors(string propertyName)
    {
        if (string.IsNullOrEmpty(propertyName))
            return null;
        if (Errors.TryGetValue(propertyName, out List<string> value))
            return value;
        return Enumerable.Empty<string>();
    }

    private void Validate()
    {
        if (_validator == null)
            return;
        var previousKeys = Errors.Select(x => x.Key).ToList();
        Errors = _validator.Validate(this)
            .Errors
            .GroupBy(x => x.PropertyName)
            .ToDictionary(x => x.Key, x => x.Select(y => y.ErrorMessage).ToList());
        if (ErrorsChanged == null)
            return;
        foreach (var key in Errors.Keys)
            ErrorsChanged.Invoke(this, new DataErrorsChangedEventArgs(key));
        foreach (var key in previousKeys.Except(Errors.Select(x => x.Key)))
            ErrorsChanged.Invoke(this, new DataErrorsChangedEventArgs(key));
    }

    public override void NotifyOfPropertyChange([CallerMemberName] string propertyName = null)
    {
        if (propertyName != null && propertyName != nameof(Errors) && propertyName != nameof(HasErrors))
            Validate();
        base.NotifyOfPropertyChange(propertyName);
    }

    public string Name { get; set; }
    public Dictionary<string, List<string>> Errors { get; private set; } = new Dictionary<string, List<string>>();
}

public class MyRecordValidator : FluentValidation.AbstractValidator<MyRecord>
{
    public MyRecordValidator()
    {
        RuleFor(x => x.Name).NotEmpty();
    }
}
}

MainWindow view

<Window x:Class="WpfDataGridValidation.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        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:local="clr-namespace:WpfDataGridValidation"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Grid>
        <DataGrid
            x:Name="dataGrid"
            ItemsSource="{Binding Records}"
            CanUserAddRows="False"
            CanUserSortColumns="False"
            CanUserResizeColumns="False"
            CanUserResizeRows="False"
            CanUserReorderColumns="False"    
            SelectionMode="Single"
            SelectionUnit="Cell"
            AutoGenerateColumns="False"
            VerticalAlignment="Top"
            HorizontalAlignment="Left"
            GridLinesVisibility="All"
            VirtualizingStackPanel.VirtualizationMode="Standard"
            HeadersVisibility="All"
            RowHeaderWidth="40"
            >
            <DataGrid.Columns>
                <DataGridTextColumn Header="Name" Binding="{Binding Name, NotifyOnSourceUpdated=True, NotifyOnTargetUpdated=True, NotifyOnValidationError=True,ValidatesOnNotifyDataErrors=True}"/>
            </DataGrid.Columns>
        </DataGrid>
    </Grid>
</Window>

MainWindow bode behind

using System.Windows;

namespace WpfDataGridValidation
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
            DataContext = new MainWindowViewModel();
        }
    }
}

App.xaml

<Application x:Class="WpfDataGridValidation.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:WpfDataGridValidation"
             StartupUri="MainWindow.xaml">
    <Application.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="pack://application:,,,/MahApps.Metro;component/Styles/Controls.xaml" />
                <ResourceDictionary Source="pack://application:,,,/MahApps.Metro;component/Styles/Fonts.xaml" />
                <ResourceDictionary Source="pack://application:,,,/MahApps.Metro;component/Styles/Colors.xaml" />
                <ResourceDictionary Source="pack://application:,,,/MahApps.Metro;component/Styles/Accents/Green.xaml" />
                <ResourceDictionary Source="pack://application:,,,/MahApps.Metro;component/Styles/Accents/BaseLight.xaml" />
            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </Application.Resources>
</Application>
timunie commented 4 years ago

Hi @danielklecha , I had a similar problem in the past. For me this was solved with this change: #3540 . This will be available in the upcomming v2.0.0. You can test this in the current prerelease if you want to. But please keep in mind that there are many breaking changes. So maybe don't try it in your productive environment.

Happy coding Tim

danielklecha commented 4 years ago

@timunie Unfortunatelly that doesn't fix my issue.

So I have this situation

  1. open app (PASS)
    • textbox is red
    • HasErrors is true
    • Validation.HasError is false
  2. start edit textbox (PASS)
    • textbox is red
    • HasErrors is true
    • Validation.HasError is true
  3. insert letter to textbox (PASS)
    • textbox is not red
    • HasErrors is false
    • Validation.HasError is false
  4. remove text, hit enter (PASS)
    • textbox is red
    • HasErrors is true
    • Validation.HasError is true
  5. start edit textbox and insert letter to it (FAIL)
    • textbox is not red
    • HasErrors is false
    • Validation.HasError is true and icon is still visible

For some reason after commit edit in point 4 Validation.HasError is not synchronized with HasErrors property...

<DataGrid.Columns>
    <DataGridTextColumn Header="Name" Binding="{Binding Name, UpdateSourceTrigger=PropertyChanged, ValidatesOnDataErrors=True, ValidatesOnNotifyDataErrors=True, NotifyOnSourceUpdated=True,NotifyOnTargetUpdated=True, NotifyOnValidationError=True}"/>
    <DataGridCheckBoxColumn Binding="{Binding HasErrors, Mode=OneWay}" Header="HasErrors"/>
    <DataGridTextColumn Binding="{Binding (Validation.HasError), Mode=OneWay, RelativeSource={RelativeSource AncestorType={x:Type DataGridRow}}}" Header="Error"/>
</DataGrid.Columns>

Also I simplify my record class and run each event manually but it doesn't help...

[DoNotNotify]
public class MyRecord : Caliburn.Micro.PropertyChangedBase, INotifyDataErrorInfo
{
    private readonly AbstractValidator<MyRecord> _validator;
    private string _name = string.Empty;
    private Dictionary<string, List<string>> _errors = new Dictionary<string, List<string>>();

    public MyRecord(FluentValidation.AbstractValidator<MyRecord> validator)
    {
        _validator = validator;
        Validate();
        this.PropertyChanged += (s, a) => Console.WriteLine($"MyRecord - PropertyChanged - {a.PropertyName}");
        this.ErrorsChanged += (s, a) => Console.WriteLine($"MyRecord - ErrorsChanged - {a.PropertyName}");
    }

    public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
    public bool HasErrors => _errors.Any();

    public IEnumerable GetErrors(string propertyName)
    {
        Console.WriteLine($"MyRecord - GetErrors - {propertyName}");
        if (string.IsNullOrEmpty(propertyName))
            return Enumerable.Empty<string>();
        if (!_errors.TryGetValue(propertyName, out List<string> value))
            return Enumerable.Empty<string>();
        Console.WriteLine(value.Count);
        return value;
    }

    private void Validate()
    {
        Console.WriteLine("MyRecord - Validate");
        if (_validator == null)
            return;
        var previousKeys = _errors.Select(x => x.Key).ToList();
        _errors = _validator.Validate(this)
            .Errors
            .GroupBy(x => x.PropertyName)
            .ToDictionary(x => x.Key, x => x.Select(y => y.ErrorMessage).ToList());
    }

    public string Name {
        get => _name;
        set
        {
            Console.WriteLine($"MyRecord - Name - begin");
            _name = value;
            NotifyOfPropertyChange("Name");
            Validate();
            ErrorsChanged(this, new DataErrorsChangedEventArgs("Name"));
            NotifyOfPropertyChange("HasErrors");
            Console.WriteLine($"MyRecord - Name - end");
        }
    }
}
timunie commented 4 years ago

Hi @danielklecha,

I am not familar with INotifyDataErrorInfo. In my App I still use IDataErrorIInfo. Can you upload a sample project here? I will hava a look if I can help then.

Happy coding Tim

danielklecha commented 4 years ago

Hi @timunie, I uploaded sample project to github. I added two grids. One with records with IDataErrorInfo and second with INotifyDataErrorInfo. In both cases I have the same situation:

  1. There is an error in datagrid
  2. edit cell and hit enter so you didn't change anything - error is still there but Validation.HasError is set to true permanently... and icon is visible
  3. hit enter second time - even if you wrote something inside cell then red border disapear but Validation.HasError is always set to true.

Maybe I do something wrong. Wrong order or something... I don't have a problem to use IDataErrorInfo interface but this also doesn't work...

I simplified dependecies - removed caliburn micro and fody and added simple base class.

Thank you for checking this – much appreciated. Best Daniel

timunie commented 4 years ago

Hi Daniel, many thanks for your sample. i now understand your annoying problem. I think this bug comes not from MahApps itself, so I don't know if we can solve this issue here. Searching the web, I found this:

In the case you find a solution, please share it here πŸ‘

Good luck Tim

timunie commented 4 years ago

Hi Daniel, one addition: If I change UpdateSourceTrigger to LostFocus the red exclamationmark works as expected for me.

Happy coding Tim

danielklecha commented 4 years ago

Hi Tim, I changed UpdateSourceTrigger to LostFocus and DataGrid with IDataErrorInfo stated working. INotifyDataErrorInfo interface still generates incorrect data.

I will try to use IDataErrorInfo in my project. In my real case I use ICustomTypeDescriptor and this is adding new layer of problems... BUT I have working example thanks to you!

Also thanks for links. My plan B is to update MahApps style for row error and change Validation.HasErrors to DataContext.HasErrors or something like that. The same for error message in tooltip. Plan C is to create custom rule which will red informations from interface properties.

Thank you very much for your help. Best Daniel

timunie commented 4 years ago

You are welcome

danielklecha commented 4 years ago

I tested DataGrid with IDataErrorInfo. Everything is working when UpdateSourceTrigger is set to LostFocus. Binding mode can't be set to TwoWay but we can use Default and control finally use TwoWay as expected. Also I tested it with my implementation of ICustomTypeDescriptor and wverything is correct.

I was not able to get working example for INotifyDataErrorInfo interface. I suspect that something is wrong with this interface ant it can't work with DataGrid.

For now I think that we can close this thread. @timunie once again thanks for help!

timunie commented 4 years ago

Hi @danielklecha Now I got the same issue as you. I moved to INotifyDataErrorInfo due to performance issues. I have at least two ideas to solve this issue.

  1. See https://stackoverflow.com/a/21433977
  2. Implement a custom row validation.

If you are still interested in a solution please reopen this issue.

Happy coding Tim

timunie commented 4 years ago

Update @danielklecha

What I have tried both ideas, without luck. For now I will just use only the Cell-Validation as it works as Expected.

Happy coding Tim

timunie commented 4 years ago

Ok one more Update:

at least I found a workaround by setting a custom RowHeaderTemplate. @danielklecha : if you are interested in this please let me know. My cuurent implementation is not ready to be shown πŸ˜„ @punker76 : are you interested in adding this to the documentation?

Happy coding Tim

danielklecha commented 4 years ago

Hi @timunie

I'm using IDataErrorInfo and so far I'm happy with that.

Yes, I'm still interested with INotifyDataErrorInfo. I suppose that you modified MahApps.Styles.DataGridRowHeader style and replace Validation.HasError with DataContext.HasErrors - or something similar. But even with that error icon don't update message in tooltip. So there should be some custom property similar to IDataErrorInfo.Error with first error and associate it to tooltip in MahApps.Templates.DataGridRow.ValidationError. I didn't had a time to update my demo app.

Anyway, it's still a workaround because of broken usage of INotifyDataErrorInfo interface.

Best Daniel

danielklecha commented 4 years ago

Hi @timunie I updated my demo with my custom style and everything is working with INotifyDataErrorInfo (second data grid). I used INotifyDataErrorInfo.HasErrors and custom property Error. Probably style should be moved to separate file. Download changes and check it.

I had a problem to use "MahApps.Brushes.Controls.Validation" and "MahApps.Brushes.Text.Validation" styles so I copied it manually. I'm not a fan of this solutions but I don't see a better way for INotifyDataErrorInfo.

Btw I didn't tested it with .NET Core - maybe they fixed this interface.

timunie commented 4 years ago

Hi @danielklecha ,

I had a problem to use "MahApps.Brushes.Controls.Validation" and "MahApps.Brushes.Text.Validation" styles so I copied it manually. I'm not a fan of this solutions but I don't see a better way for INotifyDataErrorInfo.

Note: You use MahApps v1.6.5. The StyleKeys are valid for the prerelease v2.0.0alpha.

Here is my implementation: https://github.com/timunie/WpfDataGridValidation/tree/CustomValidationExample

Validation

Please wait for the feedback of @punker76 bevore you close this issue.

Happy coding Tim

danielklecha commented 4 years ago

Thanks for example @timunie. Your solution with tooltip is better than mine (with additional property).