Over a million developers have joined DZone.
{{announcement.body}}
{{announcement.title}}

Async Validation in WPF

DZone's Guide to

Async Validation in WPF

Learn how to solve a problem that comes with validating async in WPF and learn why saving/submitting data should be disabled until the validations complete.

Free Resource

Find out how Database DevOps helps your team deliver value quicker while keeping your data safe and your organization compliant. Align DevOps for your applications with DevOps for your SQL Server databases to discover the advantages of true Database DevOps, brought to you in partnership with Redgate

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

This article provides a solution for this problem.

By the way, source code can be found here!

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. The 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 the property. The function should return an empty list if there are no errors or if there's a list of errors. The property can have only one validator. If another validator is added, the 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 three fields and imitates a 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>();
    }
}

Here are 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>

And the demo looks like this:

Image title

Image title

Image title

Image title

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;
    }
}

Image title

That's it — enjoy!

Align DevOps for your applications with DevOps for your SQL Server databases to increase speed of delivery and keep data safe. Discover true Database DevOps, brought to you in partnership with Redgate

Topics:
c# ,wpf ,database ,async ,validation ,tutorial

Opinions expressed by DZone contributors are their own.

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}