Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Async Validation in WPF

0.00/5 (No votes)
29 Aug 2017 1  
Asynchronous validation for MVVM(WPF)

Source Code

Introduction

Quite often validation requires web requests, database calls or some other kind of actions which require significant amount of time. In this case, UI should be responsible during the validation, but saving/submitting data should be disabled until validations completion.

This article provides a solution for this problem.

Helpers

PropertyHelper is used to get property name.

public class PropertyHelper
{
    public static string GetPropertyName<T>(Expression<Func<T>> propertyLambda)
    {
        var me = propertyLambda.Body as MemberExpression;

        if (me == null)
        {
            throw new ArgumentException("You must pass a lambda of the form: '() => Class.Property' or '() => object.Property'");
        }

        return me.Member.Name;
    }
}

Validatable View Model

ValidatableViewModel implements INotifyDataErrorInfo to be able to use ValidatesOnNotifyDataErrors in binding and show errors.

IsValidating property shows that validation is still in progress.

IsValid property will be set to true only when all properties are valid and no other background validations are taking place.

RegisterValidator registers validation function for property. Function should return empty list if no errors or list of errors. Property can have only one validator. In case of adding another validator previously added validator will be removed.

public abstract class ValidatableViewModel : INotifyDataErrorInfo, INotifyPropertyChanged
{
    private bool _isValidating;
    public bool IsValidating
    {
        get { return _isValidating; }
        protected set { Set(ref _isValidating, value); }
    }

    private bool _isValid = true;
    public bool IsValid
    {
        get { return _isValid; }
        protected set { Set(ref _isValid, value); }
    }

    private readonly Dictionary<string, List<string>> _validationErrors = new Dictionary<string, List<string>>();
    private readonly Dictionary<string, Guid> _lastValidationProcesses = new Dictionary<string, Guid>();
    private readonly Dictionary<string, Func<Task<List<string>>>> _validators = new Dictionary<string, Func<Task<List<string>>>>();

    protected ValidatableViewModel()
    {
        PropertyChanged += (sender, args) => Validate(args.PropertyName);
    }

    #region INotifyDataErrorInfo
    public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
    public IEnumerable GetErrors(string propertyName)
    {
        if (string.IsNullOrEmpty(propertyName) || !_validationErrors.ContainsKey(propertyName))
        {
            return new List<string>();
        }

        return _validationErrors[propertyName];
    }

    public bool HasErrors => _validationErrors.Count > 0;
    #endregion

    #region INotifyPropertyChanged
    public event PropertyChangedEventHandler PropertyChanged;
    #endregion

    public List<string> GetErrors()
    {
        return _validationErrors.SelectMany(p => p.Value).ToList();
    }

    protected void Set<T>(ref T storage, T value, [CallerMemberName] string property = null)
    {
        if (Equals(storage, value))
        {
            return;
        }

        storage = value;
        RaisePropertyChanged(property);
    }

    protected void RaisePropertyChanged(string property)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(property));
    }

    protected void RegisterValidator<TProperty>(Expression<Func<TProperty>> propertyExpression, Func<Task<List<string>>> validatorFunc)
    {
        RegisterValidator(PropertyHelper.GetPropertyName(propertyExpression), validatorFunc);
    }

    protected void RegisterValidator(string propertyName, Func<Task<List<string>>> validatorFunc)
    {
        if (_validators.ContainsKey(propertyName))
        {
            _validators.Remove(propertyName);
        }

        _validators[propertyName] = validatorFunc;
    }

    protected async Task Validate(string property)
    {
        if (string.IsNullOrWhiteSpace(property))
        {
            throw new ArgumentException();
        }

        Func<Task<List<string>>> validator;
        if (!_validators.TryGetValue(property, out validator))
        {
            return;
        }

        var validationProcessKey = Guid.NewGuid();
        _lastValidationProcesses[property] = validationProcessKey;
        IsValidating = true;
        try
        {
            var errors = await validator();
            if (_lastValidationProcesses.ContainsKey(property) && 
                _lastValidationProcesses[property] == validationProcessKey)
            {
                if (errors != null && errors.Any())
                {
                    _validationErrors[property] = errors;
                }
                else if (_validationErrors.ContainsKey(property))
                {
                    _validationErrors.Remove(property);
                }
            }
        }
        catch (Exception ex)
        {
            _validationErrors[property] = new List<string>(new[] { ex.Message });
        }
        finally
        {
            if (_lastValidationProcesses.ContainsKey(property) && 
                _lastValidationProcesses[property] == validationProcessKey)
            {
                _lastValidationProcesses.Remove(property);
            }

            IsValidating = _lastValidationProcesses.Any();
            IsValid = !_lastValidationProcesses.Any() && !_validationErrors.Any();
            OnErrorsChanged(property);
        }
    }

    protected async Task ValidateAll()
    {
        var validators = _validators;
        foreach (var propertyName in validators.Keys)
        {
            await Validate(propertyName);
        }
    }

    private void OnErrorsChanged(string propertyName)
    {
        ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
    }
}

Demo

DemoViewModel has 3 fields and imitates long validation process for these fields.

class DemoViewModel : ValidatableViewModel
{
    private string _name;
    public string Name
    {
        get
        {
            return _name;
        }

        set
        {
            Set(ref _name, value);
        }
    }

    private string _description;
    public string Description
    {
        get
        {
            return _description;
        }

        set
        {
            Set(ref _description, value);
        }
    }

    private int _number;
    public int Number
    {
        get
        {
            return _number;
        }

        set
        {
            Set(ref _number, value);
        }
    }

    public List<int> AvailableNumbers => new List<int>(new[] { 1, 2, 3, 5, 7, 11 });

    public DemoViewModel(ITaskFactory taskFactory, IProgramDispatcher programDispatcher) : base(taskFactory, programDispatcher)
    {
        RegisterValidator(() => Name, ValidateName);
        RegisterValidator(() => Description, ValidateDescription);
        RegisterValidator(() => Number, ValidateNumber);
        ValidateAll();
    }

    private List<string> ValidateName()
    {
        Task.Delay(3000).Wait();

        if (string.IsNullOrWhiteSpace(Name))
        {
            return new List<string> { "Name cannot be empty" };
        }

        if (Name.Length > 10)
        {
            return new List<string> { "Name cannot be more than 10 characters" };
        }

        return new List<string>();
    }

    private List<string> ValidateDescription()
    {
        Task.Delay(4000).Wait();

        if (string.IsNullOrWhiteSpace(Description))
        {
            return new List<string> { "Description cannot be empty" };
        }

        if (Description.Length > 50)
        {
            return new List<string> { "Name cannot be more than 50 characters" };
        }

        return new List<string>();
    }

    private List<string> ValidateNumber()
    {
        Task.Delay(2000).Wait();

        if (Number > 5)
        {
            return new List<string> { "Name cannot be more than 5" };
        }

        return new List<string>();
    }
}

Some styles to show errors.

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"

                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <ControlTemplate x:Key="GlobalErrorTemplate">
        <DockPanel>
            <Border BorderBrush="Red"

                    BorderThickness="2"

                    CornerRadius="2">
                <AdornedElementPlaceholder />
            </Border>
        </DockPanel>
    </ControlTemplate>

    <Style TargetType="{x:Type TextBox}">
        <Setter Property="Validation.ErrorTemplate"

                Value="{StaticResource GlobalErrorTemplate}" />
        <Style.Triggers>
            <Trigger Property="Validation.HasError"

                     Value="True">
                <Setter Property="ToolTip"

                        Value="{Binding RelativeSource={RelativeSource Self}, Path=(Validation.Errors)[0].ErrorContent}" />
            </Trigger>
        </Style.Triggers>
    </Style>

    <Style TargetType="{x:Type ComboBox}">
        <Setter Property="Validation.ErrorTemplate"

                Value="{StaticResource GlobalErrorTemplate}" />
        <Style.Triggers>
            <Trigger Property="Validation.HasError"

                     Value="True">
                <Setter Property="ToolTip"

                        Value="{Binding RelativeSource={RelativeSource Self}, Path=(Validation.Errors)[0].ErrorContent}" />
            </Trigger>
        </Style.Triggers>
    </Style>
    
</ResourceDictionary>

And View.

<Window x:Class="AsyncValidation.Demo.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:demo="clr-namespace:AsyncValidation.Demo"

        mc:Ignorable="d"

        SizeToContent="WidthAndHeight"

        Title="Async Validation Demo"

        d:DataContext="{d:DesignInstance IsDesignTimeCreatable=False, d:Type=demo:DemoViewModelViewModel}">
    <Window.Resources>
        <BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
    </Window.Resources>
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="120" />
            <ColumnDefinition Width="200" />
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="35"/>
        </Grid.RowDefinitions>
        <Label Grid.Row="0"

               Grid.Column="0"

               Content="Name" />
        <TextBox Grid.Row="0"

                 Grid.Column="1"

                 Margin="5"

                 Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=LostFocus, ValidatesOnNotifyDataErrors=True}"/>
        <Label Grid.Row="1"

               Grid.Column="0"

               Content="Description" />
        <TextBox Grid.Row="1"

                 Grid.Column="1"

                 Margin="5"

                 Text="{Binding Description, Mode=TwoWay, UpdateSourceTrigger=LostFocus, ValidatesOnNotifyDataErrors=True}"/>
        <Label Grid.Row="2"

               Grid.Column="0"

               Content="Number" />
        <ComboBox Grid.Row="2"

                  Grid.Column="1"

                  Margin="5"

                  ItemsSource="{Binding AvailableNumbers, ValidatesOnNotifyDataErrors=True}"

                  SelectedValue="{Binding Number, Mode=TwoWay}"/>
        <StatusBar Grid.Row="4"

                   Grid.Column="0"

                   Grid.ColumnSpan="3">
            <Label Content="Validating"

                   Visibility="{Binding IsValidating, Converter={StaticResource BooleanToVisibilityConverter}}" />
            <Label Content="Valid"

                   Visibility="{Binding IsValid, Converter={StaticResource BooleanToVisibilityConverter}}" />
        </StatusBar>
    </Grid>
</Window>

Demo looks like this.

Tests

NUnit and NSubstitute are used for writing Unit Tests.

[TestFixture]
public class ValidatableViewModelTests
{
    private class ValidatableViewModelStub : ValidatableViewModel
    {
        private string _propertyToValidate1;

        public string PropertyToValidate1
        {
            get { return _propertyToValidate1; }
            set { Set(ref _propertyToValidate1, value); }
        }

        private string _propertyToValidate2;

        public string PropertyToValidate2
        {
            get { return _propertyToValidate2; }
            set { Set(ref _propertyToValidate2, value); }
        }


        public new void RegisterValidator<T>(Expression<Func<T>> propertyExpression,
            Func<Task<List<string>>> validatorFunc) => base.RegisterValidator(propertyExpression, validatorFunc);

        public new Task ValidateAll() => base.ValidateAll();

        public new Task Validate(string property) => base.Validate(property);
    }

    private readonly string _prop1Error1 = "Property 1 Error 1";
    private readonly string _prop2Error1 = "Property 2 Error 1";
    private readonly string _prop2Error2 = "Property 2 Error 2";

    [Test]
    public void GetErrorsWhenNoErrors()
    {
        var viewModel = CreateTestViewModel(new List<string>(), new List<string>());

        viewModel.PropertyToValidate1 = "Test";
        viewModel.PropertyToValidate2 = "Test";
        var errors = viewModel.GetErrors();

        Assert.IsFalse(viewModel.HasErrors);
        Assert.IsTrue(viewModel.IsValid);
        Assert.IsFalse(viewModel.IsValidating);
        Assert.AreEqual(0, errors.Count);
    }

    [Test]
    public void GetErrorsWhenOnePropertyHasError()
    {
        var viewModel = CreateTestViewModel(new List<string> { _prop1Error1 }, new List<string>());

        viewModel.PropertyToValidate1 = "Test";
        var errors = viewModel.GetErrors();
        var prop1Errors = viewModel.GetErrors("PropertyToValidate1").Cast<string>().ToList();

        Assert.IsTrue(viewModel.HasErrors);
        Assert.IsFalse(viewModel.IsValid);
        Assert.IsFalse(viewModel.IsValidating);
        Assert.AreEqual(1, errors.Count);
        CollectionAssert.Contains(errors, _prop1Error1);
        Assert.AreEqual(1, prop1Errors.Count);
        CollectionAssert.Contains(prop1Errors, _prop1Error1);
    }

    [Test]
    public void GetErrorsWhenTwoPropertiesHaveErrors()
    {
        var viewModel = CreateTestViewModel(
            new List<string> { _prop1Error1 },
            new List<string> { _prop2Error1 });

        viewModel.PropertyToValidate1 = _prop1Error1;
        viewModel.PropertyToValidate2 = _prop2Error1;
        var errors = viewModel.GetErrors();

        var prop1Errors = viewModel.GetErrors("PropertyToValidate1").Cast<string>().ToList();
        var prop2Errors = viewModel.GetErrors("PropertyToValidate2").Cast<string>().ToList();
        Assert.AreEqual(2, errors.Count);
        Assert.AreEqual(1, prop1Errors.Count);
        Assert.AreEqual(1, prop2Errors.Count);
    }

    [Test]
    public void GetErrorsWhenTwoPropertiesHaveErrorsButOnlyOneWasValidated()
    {
        var viewModel = CreateTestViewModel(new List<string> { _prop1Error1 },
            new List<string> { _prop2Error1, _prop2Error2 });

        viewModel.PropertyToValidate2 = "Test";
        var errors = viewModel.GetErrors();
        var prop2Errors = viewModel.GetErrors("PropertyToValidate2").Cast<string>().ToList();

        Assert.IsTrue(viewModel.HasErrors);
        Assert.IsFalse(viewModel.IsValid);
        Assert.IsFalse(viewModel.IsValidating);
        Assert.AreEqual(2, errors.Count);
        CollectionAssert.Contains(errors, _prop2Error1);
        CollectionAssert.Contains(errors, _prop2Error2);
        Assert.AreEqual(2, prop2Errors.Count);
        CollectionAssert.Contains(prop2Errors, _prop2Error1);
        CollectionAssert.Contains(prop2Errors, _prop2Error1);
    }

    [Test]
    public void GetErrorsWhenValidatorException()
    {
        var viewModel = CreateTestViewModel(new List<string> { _prop1Error1 }, new List<string>());
        var validatorProp1Mock = Substitute.For<Func<Task<List<string>>>>();
        validatorProp1Mock.Invoke().Returns(Task.FromException<List<string>>(new Exception("Exception Message")));
        viewModel.RegisterValidator(() => viewModel.PropertyToValidate1, validatorProp1Mock);

        viewModel.PropertyToValidate1 = "Test";
        var errors = viewModel.GetErrors();
        var prop1Errors = viewModel.GetErrors("PropertyToValidate1").Cast<string>().ToList();

        Assert.IsTrue(viewModel.HasErrors);
        Assert.IsFalse(viewModel.IsValid);
        Assert.IsFalse(viewModel.IsValidating);
        Assert.AreEqual(1, errors.Count);
        Assert.AreEqual("Exception Message", errors[0]);
        Assert.AreEqual(1, prop1Errors.Count);
        CollectionAssert.DoesNotContain(errors, _prop1Error1);
        Assert.AreEqual("Exception Message", prop1Errors[0]);
    }

    [TestCase(null)]
    [TestCase("")]
    [TestCase(" ")]
    [TestCase("UnexistingProperty")]
    public void GetErrorsWhenWrongPropertyName(string propertyName)
    {
        var viewModel = CreateTestViewModel(new List<string> { _prop1Error1 },
            new List<string> { _prop2Error1, _prop2Error2 });

        var errors = viewModel.GetErrors(propertyName);

        CollectionAssert.IsEmpty(errors);
    }

    [Test]
    public void UseLastRegisteredValidator()
    {
        var viewModel = CreateTestViewModel(new List<string> { _prop1Error1 }, new List<string>());
        viewModel.PropertyToValidate1 = "Test";
        viewModel.RegisterValidator(() => viewModel.PropertyToValidate1, () => Task.FromResult(new List<string>()));
        viewModel.PropertyToValidate1 = "Test 2";

        var errors = viewModel.GetErrors();
        var prop1Errors = viewModel.GetErrors("PropertyToValidate1");

        Assert.IsFalse(viewModel.HasErrors);
        Assert.IsTrue(viewModel.IsValid);
        Assert.IsFalse(viewModel.IsValidating);
        CollectionAssert.IsEmpty(errors);
        CollectionAssert.IsEmpty(prop1Errors);
    }

    [Test]
    public void ValidateAll()
    {
        var viewModel = CreateTestViewModel(new List<string> { _prop1Error1 },
            new List<string> { _prop2Error1, _prop2Error2 });

        viewModel.ValidateAll();
        var errors = viewModel.GetErrors();
        var prop1Errors = viewModel.GetErrors("PropertyToValidate1").Cast<string>().ToList();
        var prop2Errors = viewModel.GetErrors("PropertyToValidate2").Cast<string>().ToList();

        Assert.IsTrue(viewModel.HasErrors);
        Assert.IsFalse(viewModel.IsValid);
        Assert.IsFalse(viewModel.IsValidating);
        Assert.AreEqual(3, errors.Count);
        CollectionAssert.Contains(errors, _prop1Error1);
        CollectionAssert.Contains(errors, _prop2Error1);
        CollectionAssert.Contains(errors, _prop2Error2);
        Assert.AreEqual(1, prop1Errors.Count);
        CollectionAssert.Contains(prop1Errors, _prop1Error1);
        Assert.AreEqual(2, prop2Errors.Count);
        CollectionAssert.Contains(prop2Errors, _prop2Error1);
        CollectionAssert.Contains(prop2Errors, _prop2Error1);
    }

    [TestCase(null)]
    [TestCase("")]
    [TestCase(" ")]
    public void ValidateWhenEmptyProperty(string propertyName)
    {
        var viewModel = CreateTestViewModel(new List<string> { _prop1Error1 },
            new List<string> { _prop2Error1, _prop2Error2 });

        Assert.That(() => viewModel.Validate(propertyName), Throws.TypeOf<ArgumentException>());
    }

    [Test]
    public void IgnorePreviousValidationResult()
    {
        var viewModel = new ValidatableViewModelStub();
        var isFirstCall = true;
        var task = Task.Run(async () =>
        {
            await Task.Delay(1000);

            return new List<string> { "First Error!!!!" };
        });
        viewModel.RegisterValidator(() => viewModel.PropertyToValidate1, () =>
        {
            if (isFirstCall)
            {
                isFirstCall = false;

                return task;
            }

            return Task.FromResult(new List<string> { "Second Error!!!!" });
        });

        viewModel.Validate("PropertyToValidate1");
        viewModel.Validate("PropertyToValidate1");
        task.Wait();
        var errors = viewModel.GetErrors();

        Assert.AreEqual("Second Error!!!!", errors[0]);
    }

    private ValidatableViewModelStub CreateTestViewModel(List<string> property1Errors, List<string> property2Errors)
    {
        var viewModel = new ValidatableViewModelStub();
        viewModel.RegisterValidator(() => viewModel.PropertyToValidate1, () => Task.FromResult(property1Errors));
        viewModel.RegisterValidator(() => viewModel.PropertyToValidate2, () => Task.FromResult(property2Errors));

        return viewModel;
    }
}

 

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here