WPF Series -7- ViewModel Validation using DataAnnotations

As we often use Data Annotations for basic validation purposes in context of C#/.NET applications (i.e. required, max length, …), we searched for a solution that allows using Data Annotations in WPF ViewModels for client side validation. Fortunately we found such a solution, which I would like to describe in this blog post.

Implement IDataErrorInfo

First of all the corresponding ViewModel classes have to implement the interface IDataErrorInfo. As we already created a base class for all ViewModel classes (ViewModelBase) we decided to implement the IDataErrorInfo interface there. The following indexer and property have to be implemented:

  • public virtual string this[string columnName] (for property errors)
  • public virtual string Error (for general model errors)

The indexer this[string columnName] returns null, if there aren’t any validation errors, otherwise the corresponding validation error message will be returned. The error property returns a message that describes any validation errors for the object or null if no validation errors occurred.

ViewModelBase.cs

/**
 * Copyright 2017 d-fens GmbH
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.Contracts;
using System.Linq;
using System.Reflection;
using System.Windows;

namespace biz.dfch.CS.ArbitraryWpfApplication.UI.ViewModels
{
    public class ViewModelBase : INotifyPropertyChanged, IDataErrorInfo
    {
        protected static readonly App CurrentApp = Application.Current as App;

        public event PropertyChangedEventHandler PropertyChanged;

        protected void RaisePropertyChangedEvent(string propertyName)
        {
            Contract.Requires(!string.IsNullOrWhiteSpace(propertyName));

            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }

        public virtual string this[string columnName]
        {
            get
            {
                var validationResults = new List<ValidationResult>();

                var property = GetType().GetProperty(columnName);
                Contract.Assert(null != property);

                var validationContext = new ValidationContext(this)
                {
                    MemberName = columnName
                };

                var isValid = Validator.TryValidateProperty(property.GetValue(this), validationContext, validationResults);
                if (isValid)
                {
                    return null;
                }

                return validationResults.First().ErrorMessage;
            }
        }

        public virtual string Error
        {
            get
            {
                var propertyInfos = GetType().GetProperties(BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.Instance);

                foreach (var propertyInfo in propertyInfos)
                {
                    var errorMsg = this[propertyInfo.Name];
                    if (null != errorMsg)
                    {
                        return errorMsg;
                    }
                }

                return null;
            }
        }
    }
}

Add DataAnnotations to ViewModel

Next the DataAnnotations have to be added to the properties of the ViewModel.

HomeViewModel.cs

/**
 * Copyright 2017 d-fens GmbH
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

using System;
using System.ComponentModel.DataAnnotations;
using biz.dfch.CS.ArbitraryWpfApplication.UI.Constants;
using biz.dfch.CS.ArbitraryWpfApplication.UI.Domain.Sewer;

namespace biz.dfch.CS.ArbitraryWpfApplication.UI.ViewModels
{
    public class HomeViewModel : ViewModelBase
    {
        #region Properties

        [Required]
        [MaxLength(Validation.MAX_LENGTH_32)]
        public string MunicipalityName
        {
            get { return CurrentApp.MunicipalityName; }
            set
            {
                CurrentApp.MunicipalityName = value;
                RaisePropertyChangedEvent(nameof(MunicipalityName));
                RaisePropertyChangedEvent(nameof(IsButtonNewProtocolEnabled));
                RaisePropertyChangedEvent(nameof(IsButtonProtocolListEnabled));
            }
        }

        [Required]
        [MaxLength(Validation.MAX_LENGTH_16)]
        public string Operator
        {
            get { return CurrentApp.Operator; }
            set
            {
                CurrentApp.Operator = value;
                RaisePropertyChangedEvent(nameof(Operator));
                RaisePropertyChangedEvent(nameof(IsButtonNewProtocolEnabled));
                RaisePropertyChangedEvent(nameof(IsButtonProtocolListEnabled));
            }
        }

        [Required]
        public DateTime CreationDate
        {
            get { return CurrentApp.CreationDate; }
            set
            {
                CurrentApp.CreationDate = value;
                RaisePropertyChangedEvent(nameof(CreationDate));
                RaisePropertyChangedEvent(nameof(IsButtonNewProtocolEnabled));
                RaisePropertyChangedEvent(nameof(IsButtonProtocolListEnabled));
            }
        }

        [Required]
        public Organisation Organisation
        {
            get { return CurrentApp.Organisation; }
            set
            {
                CurrentApp.Organisation = value;
                RaisePropertyChangedEvent(nameof(Organisation));
            }
        }

        public bool IsButtonNewProtocolEnabled => DateTime.MinValue != CreationDate
                                                  && !string.IsNullOrWhiteSpace(MunicipalityName)
                                                  && !string.IsNullOrWhiteSpace(Operator);

        public bool IsButtonProtocolListEnabled => DateTime.MinValue != CreationDate
                                                   && !string.IsNullOrWhiteSpace(MunicipalityName)
                                                   && !string.IsNullOrWhiteSpace(Operator);

        #endregion Properties
    }
}

Specify Additional Binding Settings

Last but not least in the corresponding View the following binding properties have to be specified additionally.

<TextBox Name="TextBoxMunicipalityName" Grid.Row="1" Grid.Column ="1" Text="{Binding MunicipalityName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, NotifyOnValidationError=True, ValidatesOnDataErrors=True}" />

Home.xaml

<Page x:Class="biz.dfch.CS.ArbitraryWpfApplication.UI.Pages.Home"
      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:vm="clr-namespace:biz.dfch.CS.ArbitraryWpfApplication.UI.ViewModels"
      xmlns:p="clr-namespace:biz.dfch.CS.ArbitraryWpfApplication.UI.Properties"
      xmlns:ext="clr-namespace:biz.dfch.CS.ArbitraryWpfApplication.UI.Extensions"
      xmlns:sew="clr-namespace:biz.dfch.CS.ArbitraryWpfApplication.UI.Domain.Sewer"
      xmlns:gl="clr-namespace:System.Globalization;assembly=mscorlib"
      mc:Ignorable="d"

      Title="{x:Static p:Resources.Page_Home_Title}" >

    <Page.DataContext>
        <vm:HomeViewModel></vm:HomeViewModel>
    </Page.DataContext>

    <DockPanel>
        <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right">
            <Button Name="ButtonProtocolList" Style="{DynamicResource MetroCircleButtonStyle}" Content="{StaticResource appbar_interface_list}" Click="OnButtonProtocolListClick" IsEnabled="{Binding IsButtonProtocolListEnabled}"></Button>
            <Button Name="ButtonNewProtocol" Style="{DynamicResource MetroCircleButtonStyle}" Content="{StaticResource appbar_clipboard_edit}" Click="OnButtonNewProtocolClick" IsEnabled="{Binding IsButtonNewProtocolEnabled}"></Button>
        </StackPanel>
        <Grid VerticalAlignment="Center" HorizontalAlignment="Center" FocusManager.FocusedElement="{Binding ElementName=TextBoxMunicipalityName}">
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto" />
                <RowDefinition Height="Auto" />
                <RowDefinition Height="Auto" />
                <RowDefinition Height="Auto" />
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto" />
                <ColumnDefinition Width="Auto" />
            </Grid.ColumnDefinitions>

            <Label Grid.Row="0" Grid.Column="0" Content="{x:Static p:Resources.Page_Home_Label__Date}"/>
            <Label Grid.Row="1" Grid.Column="0" Content="{x:Static p:Resources.Page_Home_Label__Municipality}"/>
            <Label Grid.Row="2" Grid.Column="0" Content="{x:Static p:Resources.Page_Home_Label__Operator}"/>
            <Label Grid.Row="3" Grid.Column="0" Content="{x:Static p:Resources.Page_Home_Label__Organisation}"/>
            <DatePicker Grid.Row="0" Grid.Column ="1" Name="DatePicker" SelectedDate="{Binding CreationDate, Mode=TwoWay, NotifyOnValidationError=True, ValidatesOnDataErrors=True, ConverterCulture={x:Static gl:CultureInfo.CurrentCulture}}" FirstDayOfWeek="Monday" />
            <TextBox Name="TextBoxMunicipalityName" Grid.Row="1" Grid.Column ="1" Text="{Binding MunicipalityName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, NotifyOnValidationError=True, ValidatesOnDataErrors=True}" />
            <TextBox Grid.Row="2" Grid.Column ="1" Text="{Binding Operator, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, NotifyOnValidationError=True, ValidatesOnDataErrors=True}" />
            <ComboBox Grid.Row="3" Grid.Column="1" MinWidth="200" HorizontalAlignment="Left" VerticalAlignment="Center" ItemsSource="{Binding Source={ext:EnumBindingSource {x:Type sew:Organisation}}}" SelectedItem="{Binding Organisation, Mode=TwoWay, NotifyOnValidationError=True, ValidatesOnDataErrors=True}" />
        </Grid>
    </DockPanel>
</Page>

That’s it. The result looks as follows.

Minimal Collection Count

Another interesting challenge we had to master was to somehow make use of Data Annotations to enforce minimal selection count for list boxes. To do so we created a custom attribute called MinimalCollectionCountAttribute that checks, if the count of an ICollection object is greater or equal the minimal count provided on initialisation.

MinimalCollectionCountAttribute.cs

/**
 * Copyright 2017 d-fens GmbH
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

using System.Collections;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.Contracts;

namespace biz.dfch.CS.ArbitraryWpfApplication.UI.Attributes
{
    public class MinimalCollectionCountAttribute : ValidationAttribute
    {
        private readonly long minimalCount;

        public MinimalCollectionCountAttribute(long minimalCount)
        {
            Contract.Requires(0 < minimalCount);

            this.minimalCount = minimalCount;
        }

        public override bool IsValid(object value)
        {
            var list = value as ICollection;
            return list?.Count >= minimalCount;
        }
    }
}

In ViewModel the attribute will be used as follows.

[MinimalCollectionCount(Validation.MININMUM_COLLECTION_COUNT_1)]
[Required]
public IList EntryElements
{
    get
    {
        return ShaftProtocol.Data.EntryElements?.ToList();
    }
    set
    {
        ShaftProtocol.Data.EntryElements = null != value ? new List<EntryElement>(value.Cast<EntryElement>()) : new List<EntryElement>();
        RaisePropertyChangedEvent(nameof(EntryElements));
        RaisePropertyChangedEvent(nameof(IsButtonSaveProtocolEnabled));
    }
}

To activate validation the already mentioned binding properties NotifyOnValidationError and ValidatesOnDataErrors have to be specified for the corresponding control in View.

<cc:CustomListBox SelectionMode="Multiple" ItemsSource="{Binding Source={ext:EnumBindingSource {x:Type sew:EntryElement}}}" SelectedItemsList="{Binding EntryElements, Mode=TwoWay, NotifyOnValidationError=True, ValidatesOnDataErrors=True}" />

NOTE: CustomListBox with SelectedItemsList property will be covered in one of the next posts.

Visually the result looks as follows.

« WPF Series -6- Globalization of Enums WPF Series -8- CustomDataGrid »

Trackbacks

  1. […] ViewModel Validation using DataAnnotations […]

  2. […] WPF Series -7- ViewModel Validation using DataAnnotations » […]

  3. […] « WPF Series -7- ViewModel Validation using DataAnnotations […]

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google+ photo

You are commenting using your Google+ account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

w

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: